dsh-side-chat-plus 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +317 -0
- package/README.zh.md +261 -0
- package/cordis.patch.yml +8 -0
- package/dsh.plugin.json +16 -0
- package/lib/client-registry.js +2949 -0
- package/lib/client-registry.js.map +1 -0
- package/lib/client.js +2949 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +840 -0
- package/lib/types/client/api.d.ts +218 -0
- package/lib/types/client/attachments/AttachmentRail.d.ts +39 -0
- package/lib/types/client/attachments/DropOverlay.d.ts +18 -0
- package/lib/types/client/attachments/ImageLightbox.d.ts +20 -0
- package/lib/types/client/attachments/MessageImage.d.ts +38 -0
- package/lib/types/client/attachments/index.d.ts +18 -0
- package/lib/types/client/index.d.ts +6 -0
- package/lib/types/client/locales.d.ts +178 -0
- package/lib/types/context-types.d.ts +390 -0
- package/lib/types/index.d.ts +7 -0
- package/lib/types/settings-shared.d.ts +24 -0
- package/lib/types/trust-fence.d.ts +20 -0
- package/lib/types/wire.d.ts +25 -0
- package/package.json +114 -0
- package/src/client/api.ts +112 -0
- package/src/client/attachments/AttachmentRail.module.css +89 -0
- package/src/client/attachments/AttachmentRail.tsx +173 -0
- package/src/client/attachments/DropOverlay.module.css +38 -0
- package/src/client/attachments/DropOverlay.tsx +62 -0
- package/src/client/attachments/ImageLightbox.module.css +44 -0
- package/src/client/attachments/ImageLightbox.tsx +58 -0
- package/src/client/attachments/MessageImage.module.css +61 -0
- package/src/client/attachments/MessageImage.tsx +120 -0
- package/src/client/attachments/index.ts +19 -0
- package/src/client/client.module.css +1032 -0
- package/src/client/index.tsx +1966 -0
- package/src/client/layout.css +16 -0
- package/src/client/locales.ts +181 -0
- package/src/context-types.ts +384 -0
- package/src/css-modules.d.ts +10 -0
- package/src/index.ts +840 -0
- package/src/settings-shared.ts +33 -0
- package/src/trust-fence.ts +70 -0
- package/src/wire.ts +81 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,840 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname } from "node:path";
|
|
4
|
+
import * as agentApi from "@deepseek-ai/dsh-agent";
|
|
5
|
+
import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
|
|
6
|
+
import { SettingsConflictError } from "@deepseek-ai/dsh-settings";
|
|
7
|
+
import z from "schemastery";
|
|
8
|
+
//#region src/settings-shared.ts
|
|
9
|
+
/**
|
|
10
|
+
* Shared side-chat preference vocabulary (types + constants), consumed by
|
|
11
|
+
* BOTH halves: the host registers the schemastery schema over these values
|
|
12
|
+
* (index.ts) and the client reads/writes them through the plugin's own
|
|
13
|
+
* fenced /sidechat settings routes. Kept free of schemastery so the browser
|
|
14
|
+
* bundle never pulls the schema runtime in.
|
|
15
|
+
*/
|
|
16
|
+
/** The user-settings namespace holding the side-chat preferences. */
|
|
17
|
+
const SUBCHAT_PREFS_NS = "dsh-side-chat";
|
|
18
|
+
/** Fallback prefs used whenever the settings document is unreachable or malformed. */
|
|
19
|
+
const SUBCHAT_PREFS_DEFAULTS = {
|
|
20
|
+
lookupDefault: false,
|
|
21
|
+
sendImmediately: true,
|
|
22
|
+
defaultPrompt: "",
|
|
23
|
+
bringMode: "draft"
|
|
24
|
+
};
|
|
25
|
+
//#endregion
|
|
26
|
+
//#region src/trust-fence.ts
|
|
27
|
+
function header(headers, name) {
|
|
28
|
+
const value = headers[name];
|
|
29
|
+
return typeof value === "string" ? value : void 0;
|
|
30
|
+
}
|
|
31
|
+
function parseAuthority(authority) {
|
|
32
|
+
try {
|
|
33
|
+
return new URL(`http://${authority}`);
|
|
34
|
+
} catch {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Whether a normalized URL hostname names the local loopback authority. */
|
|
39
|
+
function isLoopbackHostname(hostname) {
|
|
40
|
+
if (hostname === "localhost" || hostname === "[::1]") return true;
|
|
41
|
+
const parts = hostname.split(".");
|
|
42
|
+
return parts.length === 4 && parts[0] === "127" && parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) <= 255);
|
|
43
|
+
}
|
|
44
|
+
function canonicalAuthority(entry, entryUrl) {
|
|
45
|
+
const port = entryUrl.port !== "" ? entryUrl.port : new URL(`https://${entry}`).port;
|
|
46
|
+
return port === "" ? entryUrl.hostname : `${entryUrl.hostname}:${port}`;
|
|
47
|
+
}
|
|
48
|
+
function isTrustedAuthority(hostUrl, trustedHosts) {
|
|
49
|
+
return trustedHosts.some((entry) => {
|
|
50
|
+
const entryUrl = parseAuthority(entry);
|
|
51
|
+
if (entryUrl === void 0) return false;
|
|
52
|
+
return canonicalAuthority(entry, entryUrl) === entryUrl.hostname ? entryUrl.hostname === hostUrl.hostname : entryUrl.host === hostUrl.host;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Decide whether one sidechat request may reach the plugin routes.
|
|
57
|
+
* @param request - node HTTP request facts (headers).
|
|
58
|
+
* @param trustedHosts - non-loopback authorities this deployment serves.
|
|
59
|
+
* @returns true when the Host is ours (loopback or trusted) and browser markers are same-origin.
|
|
60
|
+
*/
|
|
61
|
+
function isTrustedApiRequest(request, trustedHosts) {
|
|
62
|
+
const host = header(request.headers, "host");
|
|
63
|
+
if (host === void 0) return false;
|
|
64
|
+
const hostUrl = parseAuthority(host);
|
|
65
|
+
if (hostUrl === void 0) return false;
|
|
66
|
+
if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false;
|
|
67
|
+
if (header(request.headers, "sec-fetch-site") === "cross-site") return false;
|
|
68
|
+
const origin = header(request.headers, "origin");
|
|
69
|
+
if (origin === void 0) return true;
|
|
70
|
+
try {
|
|
71
|
+
return new URL(origin).host === hostUrl.host;
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
//#endregion
|
|
77
|
+
//#region src/wire.ts
|
|
78
|
+
/** One API failure with its wire code and HTTP status. */
|
|
79
|
+
var SidechatError = class extends Error {
|
|
80
|
+
code;
|
|
81
|
+
status;
|
|
82
|
+
constructor(code, message, status = 400) {
|
|
83
|
+
super(message);
|
|
84
|
+
this.code = code;
|
|
85
|
+
this.status = status;
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
/** Body size bound of one JSON request. */
|
|
89
|
+
const MAX_BODY_BYTES = 1 << 20;
|
|
90
|
+
/** Read and parse the JSON request body (bounded; malformed → bad-request). */
|
|
91
|
+
async function readJsonBody(req) {
|
|
92
|
+
const chunks = [];
|
|
93
|
+
let total = 0;
|
|
94
|
+
for await (const chunk of req) {
|
|
95
|
+
const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
96
|
+
total += buffer.length;
|
|
97
|
+
if (total > MAX_BODY_BYTES) throw new SidechatError("bad-request", "request body too large");
|
|
98
|
+
chunks.push(buffer);
|
|
99
|
+
}
|
|
100
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
101
|
+
if (text.trim() === "") return {};
|
|
102
|
+
try {
|
|
103
|
+
return JSON.parse(text);
|
|
104
|
+
} catch {
|
|
105
|
+
throw new SidechatError("bad-request", "request body is not valid JSON");
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
/** Write a JSON response with the given status. */
|
|
109
|
+
function writeJson(res, status, body) {
|
|
110
|
+
const payload = JSON.stringify(body);
|
|
111
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
112
|
+
res.end(payload);
|
|
113
|
+
}
|
|
114
|
+
/** Write the success envelope. */
|
|
115
|
+
function writeOk(res, value) {
|
|
116
|
+
writeJson(res, 200, {
|
|
117
|
+
ok: true,
|
|
118
|
+
value
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
/** Write the failure envelope for any thrown value (unknown → internal 500). */
|
|
122
|
+
function writeError(res, error) {
|
|
123
|
+
if (error instanceof SidechatError) {
|
|
124
|
+
writeJson(res, error.status, {
|
|
125
|
+
ok: false,
|
|
126
|
+
error: {
|
|
127
|
+
code: error.code,
|
|
128
|
+
message: error.message
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
writeJson(res, 500, {
|
|
134
|
+
ok: false,
|
|
135
|
+
error: {
|
|
136
|
+
code: "internal",
|
|
137
|
+
message: error instanceof Error ? error.message : String(error)
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
/** Narrow an unknown payload value to a string, else throw bad-request. */
|
|
142
|
+
function requireString(payload, key) {
|
|
143
|
+
const value = payload?.[key];
|
|
144
|
+
if (typeof value !== "string" || value === "") throw new SidechatError("bad-request", `missing or invalid "${key}"`);
|
|
145
|
+
return value;
|
|
146
|
+
}
|
|
147
|
+
/** Narrow an unknown payload value to a boolean (default false). */
|
|
148
|
+
function optionalBoolean(payload, key) {
|
|
149
|
+
return payload?.[key] === true;
|
|
150
|
+
}
|
|
151
|
+
//#endregion
|
|
152
|
+
//#region src/index.ts
|
|
153
|
+
/**
|
|
154
|
+
* dsh-side-chat host half: the /sidechat JSON API. Every side chat is an
|
|
155
|
+
* ORDINARY session (no `origin: 'subagent'`) whose `meta.parentSession` points
|
|
156
|
+
* at the conversation that launched it, archived immediately so it appears in
|
|
157
|
+
* neither the main session list nor the subagent catalog, and driven directly
|
|
158
|
+
* through the live agent (followup). Model / reasoning-effort / permission are
|
|
159
|
+
* inherited from the launching conversation at creation and adjustable later.
|
|
160
|
+
*
|
|
161
|
+
* All routes pass the same browser-trust fence as the /api gateway (loopback
|
|
162
|
+
* or trusted authority; cross-site markers refuse).
|
|
163
|
+
*/
|
|
164
|
+
/** Plugin identity for cordis.yml rows. */
|
|
165
|
+
const name = "dsh-side-chat-plus";
|
|
166
|
+
/** Services required before mounting. */
|
|
167
|
+
const inject = [
|
|
168
|
+
"webServer",
|
|
169
|
+
"sessions",
|
|
170
|
+
"agents",
|
|
171
|
+
"workspaceRegistry",
|
|
172
|
+
"sessionQuery",
|
|
173
|
+
"sandboxPolicy",
|
|
174
|
+
"permissionPresets",
|
|
175
|
+
"agentPresets",
|
|
176
|
+
"llm",
|
|
177
|
+
"attachments",
|
|
178
|
+
"commands"
|
|
179
|
+
];
|
|
180
|
+
/**
|
|
181
|
+
* Couple a side chat's selection to its agent so prompt assembly and request
|
|
182
|
+
* routing switch together. Read through the namespace object rather than a
|
|
183
|
+
* named import: a named import fails the ESM link on any build that drops the
|
|
184
|
+
* export, while this degrades to the request-only waterfall in `start`.
|
|
185
|
+
*/
|
|
186
|
+
const installModelSelection = agentApi.installModelSelection;
|
|
187
|
+
/** Durable record list file (cleanup is a later feature; the list is the record). */
|
|
188
|
+
const RECORD_FILE = "dsh-side-chat-sessions.json";
|
|
189
|
+
/** The record-list file path under the harness home. */
|
|
190
|
+
function recordFilePath() {
|
|
191
|
+
return dshHomePath(RECORD_FILE);
|
|
192
|
+
}
|
|
193
|
+
/** Persist the live side-chat record list (the "record" a future cleanup consumes). */
|
|
194
|
+
async function writeRecords(records) {
|
|
195
|
+
const path = recordFilePath();
|
|
196
|
+
await mkdir(dirname(path), { recursive: true });
|
|
197
|
+
await writeFile(path, JSON.stringify(records, null, 2), "utf8");
|
|
198
|
+
}
|
|
199
|
+
/** The lookup guidance appended as an extra text block (hidden from the UI). */
|
|
200
|
+
const GUIDANCE_MARKER = "[sidechat-guidance]";
|
|
201
|
+
const LOOKUP_GUIDANCE = `${GUIDANCE_MARKER}[需要时,请读取工作区文件或查阅发起此问题的父会话记录来补充信息;若已尽力仍不足,请说明限制。]`;
|
|
202
|
+
const NO_LOOKUP_GUIDANCE = `${GUIDANCE_MARKER}[请仅基于上述内容直接回答,不要主动查阅工作区文件或父会话记录。]`;
|
|
203
|
+
/** Legacy (pre-marker) guidance prefixes, filtered so older messages hide too. */
|
|
204
|
+
const LEGACY_GUIDANCE_PREFIXES = ["[需要时,请读取工作区文件", "[请仅基于上述内容"];
|
|
205
|
+
/** Decode one base64 string into bytes (host-side; Node Buffer). */
|
|
206
|
+
function decodeBase64(data) {
|
|
207
|
+
return new Uint8Array(Buffer.from(data, "base64"));
|
|
208
|
+
}
|
|
209
|
+
/** A user-role message value `agent.followup` accepts (identity + content + source). */
|
|
210
|
+
function userMessage(content) {
|
|
211
|
+
return {
|
|
212
|
+
id: randomUUID(),
|
|
213
|
+
role: "user",
|
|
214
|
+
content,
|
|
215
|
+
source: { kind: "user" }
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
/** Fold a raw session event log into a minimal user/assistant transcript with image refs. */
|
|
219
|
+
function foldTranscript(events) {
|
|
220
|
+
const fold = (content) => {
|
|
221
|
+
if (!Array.isArray(content)) return [];
|
|
222
|
+
const blocks = [];
|
|
223
|
+
for (const raw of content) {
|
|
224
|
+
const b = raw;
|
|
225
|
+
if (b?.type === "text" && typeof b.text === "string") {
|
|
226
|
+
const text = b.text;
|
|
227
|
+
if (!(text.startsWith(GUIDANCE_MARKER) || LEGACY_GUIDANCE_PREFIXES.some((prefix) => text.startsWith(prefix)))) blocks.push({
|
|
228
|
+
type: "text",
|
|
229
|
+
text
|
|
230
|
+
});
|
|
231
|
+
} else if (b?.type === "image" && b.attachment !== null && typeof b.attachment === "object") blocks.push({
|
|
232
|
+
type: "image",
|
|
233
|
+
ref: b.attachment
|
|
234
|
+
});
|
|
235
|
+
else if (b?.type === "reasoning" && typeof b.text === "string") blocks.push({
|
|
236
|
+
type: "reasoning",
|
|
237
|
+
text: b.text
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
return blocks;
|
|
241
|
+
};
|
|
242
|
+
const messages = [];
|
|
243
|
+
for (const event of events) if (event.type === "user/message") {
|
|
244
|
+
const source = event.data.source;
|
|
245
|
+
if (source !== void 0 && source.kind !== "user") continue;
|
|
246
|
+
const blocks = fold(event.data.content);
|
|
247
|
+
if (blocks.length > 0) messages.push({
|
|
248
|
+
role: "user",
|
|
249
|
+
blocks
|
|
250
|
+
});
|
|
251
|
+
} else if (event.type === "assistant/message") {
|
|
252
|
+
const message = event.data.message;
|
|
253
|
+
const blocks = fold(message?.content);
|
|
254
|
+
if (blocks.length > 0) messages.push({
|
|
255
|
+
role: "assistant",
|
|
256
|
+
blocks
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
return messages;
|
|
260
|
+
}
|
|
261
|
+
/** The start time of the still-open turn, or undefined when no turn is running. */
|
|
262
|
+
function openTurnStart(events) {
|
|
263
|
+
let lastStart;
|
|
264
|
+
let lastEnd;
|
|
265
|
+
for (const event of events) if (event.type === "turn/start") lastStart = event.time;
|
|
266
|
+
else if (event.type === "turn/end") lastEnd = event.time;
|
|
267
|
+
if (lastStart === void 0) return void 0;
|
|
268
|
+
if (lastEnd !== void 0 && lastEnd >= lastStart) return void 0;
|
|
269
|
+
return lastStart;
|
|
270
|
+
}
|
|
271
|
+
/** Schemastery schema for the user-facing preferences (validated by the settings service). */
|
|
272
|
+
const PrefsSchema = z.object({
|
|
273
|
+
lookupDefault: z.boolean().default(SUBCHAT_PREFS_DEFAULTS.lookupDefault),
|
|
274
|
+
sendImmediately: z.boolean().default(SUBCHAT_PREFS_DEFAULTS.sendImmediately),
|
|
275
|
+
defaultPrompt: z.string().default(SUBCHAT_PREFS_DEFAULTS.defaultPrompt),
|
|
276
|
+
bringMode: z.union(["draft", "context"]).default(SUBCHAT_PREFS_DEFAULTS.bringMode)
|
|
277
|
+
});
|
|
278
|
+
/** The API method table bound to the plugin context and the live side-chat map. */
|
|
279
|
+
function buildApi(ctx, sideChats, getSettings) {
|
|
280
|
+
/** Persist the current live records (best-effort; never blocks the API). */
|
|
281
|
+
const persist = () => {
|
|
282
|
+
writeRecords([...sideChats.values()].map((record) => ({
|
|
283
|
+
childId: record.childId,
|
|
284
|
+
parentSessionId: record.parentSessionId,
|
|
285
|
+
createdAt: record.createdAt
|
|
286
|
+
}))).catch((error) => {
|
|
287
|
+
console.warn("[dsh-side-chat] record write failed:", error instanceof Error ? error.message : String(error));
|
|
288
|
+
});
|
|
289
|
+
};
|
|
290
|
+
/** Narrow a payload value to a non-empty prompt content array. */
|
|
291
|
+
const requireContent = (payload) => {
|
|
292
|
+
const content = payload?.content;
|
|
293
|
+
if (!Array.isArray(content) || content.length === 0) throw new SidechatError("bad-request", "missing or invalid \"content\"");
|
|
294
|
+
const parts = [];
|
|
295
|
+
for (const raw of content) {
|
|
296
|
+
const part = raw;
|
|
297
|
+
if (part?.type === "text" && typeof part.text === "string" && part.text !== "") parts.push({
|
|
298
|
+
type: "text",
|
|
299
|
+
text: part.text
|
|
300
|
+
});
|
|
301
|
+
else if (part?.type === "image" && typeof part.mediaType === "string" && typeof part.data === "string" && part.data !== "") parts.push({
|
|
302
|
+
type: "image",
|
|
303
|
+
mediaType: part.mediaType,
|
|
304
|
+
data: part.data,
|
|
305
|
+
...typeof part.name === "string" && part.name !== "" ? { name: part.name } : {}
|
|
306
|
+
});
|
|
307
|
+
else throw new SidechatError("bad-request", "invalid content block");
|
|
308
|
+
}
|
|
309
|
+
if (parts.every((p) => p.type !== "text")) throw new SidechatError("bad-request", "content must include text");
|
|
310
|
+
return parts;
|
|
311
|
+
};
|
|
312
|
+
/** Promote browser base64 images to durable references; append hidden guidance. */
|
|
313
|
+
const durableContent = async (parts, lookupEnabled) => {
|
|
314
|
+
const limits = ctx.attachments.imageLimits;
|
|
315
|
+
if (parts.filter((p) => p.type === "image").length > limits.maxImagesPerMessage) throw new SidechatError("too-many-images", `prompt exceeds the ${limits.maxImagesPerMessage}-image limit`, 400);
|
|
316
|
+
const blocks = [];
|
|
317
|
+
for (const part of parts) if (part.type === "text") blocks.push({
|
|
318
|
+
type: "text",
|
|
319
|
+
text: part.text
|
|
320
|
+
});
|
|
321
|
+
else {
|
|
322
|
+
const data = decodeBase64(part.data);
|
|
323
|
+
await ctx.attachments.validateImage({
|
|
324
|
+
data,
|
|
325
|
+
mediaType: part.mediaType,
|
|
326
|
+
...part.name === void 0 ? {} : { name: part.name }
|
|
327
|
+
});
|
|
328
|
+
const attachment = await ctx.attachments.saveImage({
|
|
329
|
+
data,
|
|
330
|
+
mediaType: part.mediaType,
|
|
331
|
+
...part.name === void 0 ? {} : { name: part.name }
|
|
332
|
+
});
|
|
333
|
+
blocks.push({
|
|
334
|
+
type: "image",
|
|
335
|
+
attachment
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
blocks.push({
|
|
339
|
+
type: "text",
|
|
340
|
+
text: lookupEnabled ? LOOKUP_GUIDANCE : NO_LOOKUP_GUIDANCE
|
|
341
|
+
});
|
|
342
|
+
return blocks;
|
|
343
|
+
};
|
|
344
|
+
/** Resolve the live launching agent (the side chat's durable parent must be live to start/continue). */
|
|
345
|
+
const parentOf = (parentSessionId) => {
|
|
346
|
+
const parent = ctx.agents.get(parentSessionId);
|
|
347
|
+
if (parent === void 0) throw new SidechatError("parent-unavailable", "the launching conversation is not live; reopen it to start or continue a side chat", 409);
|
|
348
|
+
return parent;
|
|
349
|
+
};
|
|
350
|
+
/** The parent conversation's current model selection (for staged-mode display). */
|
|
351
|
+
const inherit = (payload) => {
|
|
352
|
+
const parentSessionId = requireString(payload, "parentSessionId");
|
|
353
|
+
const parent = parentOf(parentSessionId);
|
|
354
|
+
const parentConfig = parent.session.requestHeader?.()?.config;
|
|
355
|
+
return {
|
|
356
|
+
provider: parentConfig?.provider ?? parent.options.provider ?? "",
|
|
357
|
+
model: parentConfig?.model ?? parent.options.model ?? "",
|
|
358
|
+
...parentConfig?.reasoningEffort === void 0 ? {} : { reasoningEffort: parentConfig.reasoningEffort }
|
|
359
|
+
};
|
|
360
|
+
};
|
|
361
|
+
/** Resolve the live side-chat agent behind a childId. */
|
|
362
|
+
const childOf = (childId) => {
|
|
363
|
+
const record = sideChats.get(childId);
|
|
364
|
+
if (record !== void 0) return record.handle.agent;
|
|
365
|
+
const live = ctx.agents.get(childId);
|
|
366
|
+
if (live !== void 0) return live;
|
|
367
|
+
throw new SidechatError("child-unavailable", `side chat "${childId}" is not live`, 409);
|
|
368
|
+
};
|
|
369
|
+
/** Resolve the live side-chat session (for permission writes). */
|
|
370
|
+
const sessionOf = (childId) => {
|
|
371
|
+
const child = childOf(childId);
|
|
372
|
+
const session = ctx.sessions.get(childId) ?? child.session;
|
|
373
|
+
if (session === void 0) throw new SidechatError("child-unavailable", `side chat "${childId}" has no session`, 409);
|
|
374
|
+
return session;
|
|
375
|
+
};
|
|
376
|
+
/** List the host slash commands available to one side-chat agent. */
|
|
377
|
+
const commands = (payload) => {
|
|
378
|
+
const childId = requireString(payload, "childId");
|
|
379
|
+
const child = childOf(childId);
|
|
380
|
+
return { commands: ctx.commands.list(child).map((c) => ({
|
|
381
|
+
name: c.name,
|
|
382
|
+
description: c.description
|
|
383
|
+
})) };
|
|
384
|
+
};
|
|
385
|
+
/** Execute one host slash command against a side-chat agent. */
|
|
386
|
+
const command = async (payload) => {
|
|
387
|
+
const childId = requireString(payload, "childId");
|
|
388
|
+
const line = requireString(payload, "line");
|
|
389
|
+
const child = childOf(childId);
|
|
390
|
+
return { executed: await ctx.commands.execute(child, line, new AbortController().signal) !== void 0 };
|
|
391
|
+
};
|
|
392
|
+
/** Fold one side-chat agent's plan/goal state for its composer chrome. */
|
|
393
|
+
const state = (payload) => {
|
|
394
|
+
const childId = requireString(payload, "childId");
|
|
395
|
+
const events = childOf(childId).session.events ?? [];
|
|
396
|
+
let planActive = false;
|
|
397
|
+
let planWanted = null;
|
|
398
|
+
let goal = null;
|
|
399
|
+
for (const event of events) {
|
|
400
|
+
const data = event.data;
|
|
401
|
+
if (event.type === "command/run" && data.name === "plan") {
|
|
402
|
+
if (data.args === void 0) continue;
|
|
403
|
+
const wanted = String(data.args).trim() !== "off";
|
|
404
|
+
if (wanted !== planWanted) planWanted = wanted;
|
|
405
|
+
} else if (event.type === "plan/mode") {
|
|
406
|
+
planActive = data.active === true;
|
|
407
|
+
planWanted = null;
|
|
408
|
+
} else if (event.type === "goal/change") {
|
|
409
|
+
if (data.operation === "clear") goal = null;
|
|
410
|
+
else if (data.goal !== null && typeof data.goal === "object") {
|
|
411
|
+
const g = data.goal;
|
|
412
|
+
if (typeof g.id === "string" && typeof g.objective === "string") goal = {
|
|
413
|
+
id: g.id,
|
|
414
|
+
objective: g.objective
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return {
|
|
420
|
+
plan: {
|
|
421
|
+
active: planActive,
|
|
422
|
+
pending: planWanted !== null && planWanted !== planActive
|
|
423
|
+
},
|
|
424
|
+
goal
|
|
425
|
+
};
|
|
426
|
+
};
|
|
427
|
+
/** Create one side chat: inherit, archive, record, deliver the first prompt. */
|
|
428
|
+
const start = async (payload) => {
|
|
429
|
+
const parentSessionId = requireString(payload, "parentSessionId");
|
|
430
|
+
const content = requireContent(payload);
|
|
431
|
+
const lookupEnabled = optionalBoolean(payload, "lookupEnabled");
|
|
432
|
+
const parent = parentOf(parentSessionId);
|
|
433
|
+
const record = payload;
|
|
434
|
+
const parentConfig = parent.session.requestHeader?.()?.config;
|
|
435
|
+
const parentProvider = parentConfig?.provider ?? parent.options.provider ?? "";
|
|
436
|
+
const parentModel = parentConfig?.model ?? parent.options.model ?? "";
|
|
437
|
+
const provider = typeof record.provider === "string" && record.provider !== "" ? record.provider : parentProvider;
|
|
438
|
+
const model = typeof record.model === "string" && record.model !== "" ? record.model : parentModel;
|
|
439
|
+
const maxTokens = parentConfig?.maxTokens ?? parent.options.maxTokens;
|
|
440
|
+
const reasoningEffort = (typeof record.reasoningEffort === "string" && record.reasoningEffort !== "" ? record.reasoningEffort : void 0) ?? parentConfig?.reasoningEffort;
|
|
441
|
+
const cwd = parent.session.header.cwd;
|
|
442
|
+
const selection = {
|
|
443
|
+
current: {
|
|
444
|
+
provider,
|
|
445
|
+
model,
|
|
446
|
+
...reasoningEffort === void 0 ? {} : { reasoningEffort }
|
|
447
|
+
},
|
|
448
|
+
assembled: void 0
|
|
449
|
+
};
|
|
450
|
+
const parentCtx = parent.ctx;
|
|
451
|
+
const childId = `subchat-${randomUUID()}`;
|
|
452
|
+
const handle = await ctx.agents.create({
|
|
453
|
+
sessionId: childId,
|
|
454
|
+
meta: {
|
|
455
|
+
...cwd === void 0 ? {} : { cwd },
|
|
456
|
+
parentSession: parentSessionId
|
|
457
|
+
},
|
|
458
|
+
agentOptions: {
|
|
459
|
+
provider,
|
|
460
|
+
model,
|
|
461
|
+
...maxTokens === void 0 ? {} : { maxTokens }
|
|
462
|
+
},
|
|
463
|
+
setup: (agentCtx) => {
|
|
464
|
+
if (parentCtx !== void 0) ctx.agentPresets.composeFrom(agentCtx, parentCtx);
|
|
465
|
+
if (installModelSelection !== void 0) {
|
|
466
|
+
installModelSelection(agentCtx, selection);
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
agentCtx.on("agent/request", async (_payload, next) => {
|
|
470
|
+
const resolved = await next();
|
|
471
|
+
const sel = selection.current;
|
|
472
|
+
if (sel === void 0) return resolved;
|
|
473
|
+
const { reasoningEffort: _drop, ...rest } = resolved;
|
|
474
|
+
return {
|
|
475
|
+
...rest,
|
|
476
|
+
provider: sel.provider,
|
|
477
|
+
model: sel.model,
|
|
478
|
+
...sel.reasoningEffort === void 0 ? {} : { reasoningEffort: sel.reasoningEffort }
|
|
479
|
+
};
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
try {
|
|
484
|
+
const explicitPreset = typeof record.preset === "string" && record.preset !== "" ? record.preset : void 0;
|
|
485
|
+
const parentPreset = ctx.permissionPresets.current(parent.session.events ?? []);
|
|
486
|
+
const preset = explicitPreset ?? parentPreset;
|
|
487
|
+
if (preset !== "custom") ctx.permissionPresets.set(handle.agent.session, preset);
|
|
488
|
+
} catch (error) {
|
|
489
|
+
console.warn("[dsh-side-chat] permission inherit failed:", error instanceof Error ? error.message : String(error));
|
|
490
|
+
}
|
|
491
|
+
await ctx.workspaceRegistry.archiveSession(childId);
|
|
492
|
+
sideChats.set(childId, {
|
|
493
|
+
childId,
|
|
494
|
+
parentSessionId,
|
|
495
|
+
handle,
|
|
496
|
+
selection,
|
|
497
|
+
createdAt: Date.now()
|
|
498
|
+
});
|
|
499
|
+
persist();
|
|
500
|
+
handle.agent.followup(userMessage(await durableContent(content, lookupEnabled)));
|
|
501
|
+
return {
|
|
502
|
+
childId,
|
|
503
|
+
provider,
|
|
504
|
+
model,
|
|
505
|
+
...reasoningEffort === void 0 ? {} : { reasoningEffort }
|
|
506
|
+
};
|
|
507
|
+
};
|
|
508
|
+
/** Deliver one later message to an existing side chat. */
|
|
509
|
+
const followup = async (payload) => {
|
|
510
|
+
const childId = requireString(payload, "childId");
|
|
511
|
+
const content = requireContent(payload);
|
|
512
|
+
const lookupEnabled = optionalBoolean(payload, "lookupEnabled");
|
|
513
|
+
childOf(childId).followup(userMessage(await durableContent(content, lookupEnabled)));
|
|
514
|
+
return { accepted: true };
|
|
515
|
+
};
|
|
516
|
+
/** List the side chats launched by one parent conversation. */
|
|
517
|
+
const list = (payload) => {
|
|
518
|
+
const parentSessionId = requireString(payload, "parentSessionId");
|
|
519
|
+
const items = [];
|
|
520
|
+
for (const record of sideChats.values()) {
|
|
521
|
+
if (record.parentSessionId !== parentSessionId) continue;
|
|
522
|
+
const runningSince = openTurnStart(record.handle.agent.session.events ?? []);
|
|
523
|
+
items.push({
|
|
524
|
+
childId: record.childId,
|
|
525
|
+
running: runningSince !== void 0,
|
|
526
|
+
...runningSince !== void 0 ? { runningSince } : {}
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
return { items };
|
|
530
|
+
};
|
|
531
|
+
/** Interrupt the active side chat's current turn (user-initiated stop). */
|
|
532
|
+
const stop = (payload) => {
|
|
533
|
+
const childId = requireString(payload, "childId");
|
|
534
|
+
childOf(childId).cancel({ kind: "user" });
|
|
535
|
+
return { accepted: true };
|
|
536
|
+
};
|
|
537
|
+
/** Fold one side chat's transcript. */
|
|
538
|
+
const history = async (payload) => {
|
|
539
|
+
const childId = requireString(payload, "childId");
|
|
540
|
+
return { messages: foldTranscript((await ctx.sessionQuery.readSession(childId)).events) };
|
|
541
|
+
};
|
|
542
|
+
/** The deployment-resolved image policy (for client-side fast-path checks). */
|
|
543
|
+
const limits = () => {
|
|
544
|
+
const l = ctx.attachments.imageLimits;
|
|
545
|
+
return {
|
|
546
|
+
mediaTypes: [...l.mediaTypes],
|
|
547
|
+
maxImageBytes: l.maxImageBytes,
|
|
548
|
+
maxImagesPerMessage: l.maxImagesPerMessage,
|
|
549
|
+
maxMessageImageBytes: l.maxMessageImageBytes,
|
|
550
|
+
maxImagePixels: l.maxImagePixels
|
|
551
|
+
};
|
|
552
|
+
};
|
|
553
|
+
/** Read one durable image's bytes for transcript rendering. */
|
|
554
|
+
const attachment = async (payload) => {
|
|
555
|
+
const childId = requireString(payload, "childId");
|
|
556
|
+
const attachmentId = requireString(payload, "attachmentId");
|
|
557
|
+
const snapshot = await ctx.sessionQuery.readSession(childId);
|
|
558
|
+
for (const message of foldTranscript(snapshot.events)) for (const block of message.blocks) if (block.type === "image" && block.ref.attachmentId === attachmentId) {
|
|
559
|
+
const stored = await ctx.attachments.readImage(block.ref);
|
|
560
|
+
return {
|
|
561
|
+
mediaType: stored.ref.mediaType,
|
|
562
|
+
data: Buffer.from(stored.data).toString("base64")
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
throw new SidechatError("not-found", `image "${attachmentId}" not found`, 404);
|
|
566
|
+
};
|
|
567
|
+
/** Adjust one side chat's model / reasoning-effort. */
|
|
568
|
+
const selectModel = (payload) => {
|
|
569
|
+
const childId = requireString(payload, "childId");
|
|
570
|
+
const provider = requireString(payload, "provider");
|
|
571
|
+
const model = requireString(payload, "model");
|
|
572
|
+
const record = sideChats.get(childId);
|
|
573
|
+
if (record === void 0) throw new SidechatError("child-unavailable", `side chat "${childId}" is not live`, 409);
|
|
574
|
+
const reasoningEffort = payload?.reasoningEffort;
|
|
575
|
+
record.selection.current = {
|
|
576
|
+
provider,
|
|
577
|
+
model,
|
|
578
|
+
...typeof reasoningEffort === "string" && reasoningEffort !== "" ? { reasoningEffort } : {}
|
|
579
|
+
};
|
|
580
|
+
return { accepted: true };
|
|
581
|
+
};
|
|
582
|
+
/** Adjust one side chat's permission preset. */
|
|
583
|
+
const selectPermission = (payload) => {
|
|
584
|
+
const childId = requireString(payload, "childId");
|
|
585
|
+
const presetName = requireString(payload, "presetName");
|
|
586
|
+
const session = sessionOf(childId);
|
|
587
|
+
ctx.permissionPresets.set(session, presetName);
|
|
588
|
+
return { accepted: true };
|
|
589
|
+
};
|
|
590
|
+
/** Close one side chat (tears down its agent and session). */
|
|
591
|
+
const dispose = async (payload) => {
|
|
592
|
+
const childId = requireString(payload, "childId");
|
|
593
|
+
const record = sideChats.get(childId);
|
|
594
|
+
if (record !== void 0) {
|
|
595
|
+
sideChats.delete(childId);
|
|
596
|
+
try {
|
|
597
|
+
await record.handle.dispose();
|
|
598
|
+
} catch (error) {
|
|
599
|
+
console.warn("[dsh-side-chat] dispose failed:", error instanceof Error ? error.message : String(error));
|
|
600
|
+
}
|
|
601
|
+
persist();
|
|
602
|
+
}
|
|
603
|
+
return { accepted: true };
|
|
604
|
+
};
|
|
605
|
+
/** Summarize one piece of text with the side chat's (inherited) model. */
|
|
606
|
+
const summarize = async (payload) => {
|
|
607
|
+
const parentSessionId = requireString(payload, "parentSessionId");
|
|
608
|
+
const text = requireString(payload, "text");
|
|
609
|
+
const record = payload;
|
|
610
|
+
const parent = parentOf(parentSessionId);
|
|
611
|
+
const parentConfig = parent.session.requestHeader?.()?.config;
|
|
612
|
+
const provider = typeof record.provider === "string" && record.provider !== "" ? record.provider : parentConfig?.provider ?? parent.options.provider ?? "";
|
|
613
|
+
const model = typeof record.model === "string" && record.model !== "" ? record.model : parentConfig?.model ?? parent.options.model ?? "";
|
|
614
|
+
if (provider === "" || model === "") throw new SidechatError("bad-request", "no model available for summarization");
|
|
615
|
+
const reasoningEffort = typeof record.reasoningEffort === "string" && record.reasoningEffort !== "" ? record.reasoningEffort : parentConfig?.reasoningEffort;
|
|
616
|
+
const prompt = (typeof record.locale === "string" && record.locale === "en" ? "en" : "zh") === "en" ? `Summarize the following content concisely. Keep the key points and output only the summary:\n\n${text}` : `请对以下内容做简明扼要的摘要,保留关键信息,只输出摘要本身:\n\n${text}`;
|
|
617
|
+
const chunks = ctx.llm.stream({
|
|
618
|
+
provider,
|
|
619
|
+
model,
|
|
620
|
+
...reasoningEffort === void 0 ? {} : { reasoningEffort },
|
|
621
|
+
maxTokens: 1024,
|
|
622
|
+
messages: [{
|
|
623
|
+
id: randomUUID(),
|
|
624
|
+
role: "user",
|
|
625
|
+
content: [{
|
|
626
|
+
type: "text",
|
|
627
|
+
text: prompt
|
|
628
|
+
}],
|
|
629
|
+
source: { kind: "user" }
|
|
630
|
+
}]
|
|
631
|
+
});
|
|
632
|
+
let summary = "";
|
|
633
|
+
for await (const chunk of chunks) if (chunk.type === "text-delta" && typeof chunk.text === "string") summary += chunk.text;
|
|
634
|
+
const trimmed = summary.trim();
|
|
635
|
+
if (trimmed === "") throw new SidechatError("summarize-empty", "the model returned no summary", 502);
|
|
636
|
+
return { summary: trimmed };
|
|
637
|
+
};
|
|
638
|
+
/** Inject one piece of text into the main conversation as a collapsed context row. */
|
|
639
|
+
const inject = (payload) => {
|
|
640
|
+
const parentSessionId = requireString(payload, "parentSessionId");
|
|
641
|
+
const text = requireString(payload, "text");
|
|
642
|
+
const record = payload;
|
|
643
|
+
const summary = typeof record.summary === "string" && record.summary.trim() !== "" ? record.summary.trim() : "从侧边聊天带回";
|
|
644
|
+
parentOf(parentSessionId).inject({
|
|
645
|
+
id: randomUUID(),
|
|
646
|
+
role: "user",
|
|
647
|
+
content: [{
|
|
648
|
+
type: "text",
|
|
649
|
+
text
|
|
650
|
+
}],
|
|
651
|
+
source: {
|
|
652
|
+
kind: "plugin",
|
|
653
|
+
plugin: "dsh-side-chat",
|
|
654
|
+
form: "notice",
|
|
655
|
+
summary
|
|
656
|
+
}
|
|
657
|
+
});
|
|
658
|
+
return { accepted: true };
|
|
659
|
+
};
|
|
660
|
+
/** Full model directory: provider groups → models → reasoning efforts. */
|
|
661
|
+
const directory = async () => {
|
|
662
|
+
const providers = ctx.llm.listProviders();
|
|
663
|
+
const groups = [];
|
|
664
|
+
for (const provider of providers) {
|
|
665
|
+
const listed = await ctx.llm.listModels(provider.id);
|
|
666
|
+
const models = [];
|
|
667
|
+
for (const model of listed) {
|
|
668
|
+
const entry = {
|
|
669
|
+
id: model.id,
|
|
670
|
+
name: model.name,
|
|
671
|
+
...model.description === void 0 ? {} : { description: model.description }
|
|
672
|
+
};
|
|
673
|
+
try {
|
|
674
|
+
const info = await ctx.llm.resolveModelInfo(provider.id, model.id);
|
|
675
|
+
if (info.reasoning !== void 0) entry.reasoning = {
|
|
676
|
+
efforts: info.reasoning.efforts.map((e) => ({
|
|
677
|
+
id: e.id,
|
|
678
|
+
name: e.name
|
|
679
|
+
})),
|
|
680
|
+
...info.reasoning.defaultEffort === void 0 ? {} : { defaultEffort: info.reasoning.defaultEffort }
|
|
681
|
+
};
|
|
682
|
+
} catch {}
|
|
683
|
+
models.push(entry);
|
|
684
|
+
}
|
|
685
|
+
groups.push({
|
|
686
|
+
id: provider.id,
|
|
687
|
+
name: provider.name,
|
|
688
|
+
models
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
return { groups };
|
|
692
|
+
};
|
|
693
|
+
/** Permission-preset options for the side-chat selector. */
|
|
694
|
+
const permissions = () => {
|
|
695
|
+
const select = ctx.permissionPresets.selectFor({});
|
|
696
|
+
return {
|
|
697
|
+
options: select.options.map((o) => ({
|
|
698
|
+
value: o.value,
|
|
699
|
+
name: o.name,
|
|
700
|
+
...o.description === void 0 ? {} : { description: o.description }
|
|
701
|
+
})),
|
|
702
|
+
current: select.currentValue
|
|
703
|
+
};
|
|
704
|
+
};
|
|
705
|
+
return {
|
|
706
|
+
"sidechat.start": start,
|
|
707
|
+
"sidechat.followup": followup,
|
|
708
|
+
"sidechat.list": list,
|
|
709
|
+
"sidechat.history": history,
|
|
710
|
+
"sidechat.stop": stop,
|
|
711
|
+
"sidechat.selectModel": selectModel,
|
|
712
|
+
"sidechat.selectPermission": selectPermission,
|
|
713
|
+
"sidechat.summarize": summarize,
|
|
714
|
+
"sidechat.inject": inject,
|
|
715
|
+
"sidechat.directory": directory,
|
|
716
|
+
"sidechat.permissions": permissions,
|
|
717
|
+
"sidechat.limits": limits,
|
|
718
|
+
"sidechat.attachment": attachment,
|
|
719
|
+
"sidechat.inherit": inherit,
|
|
720
|
+
"sidechat.commands": commands,
|
|
721
|
+
"sidechat.command": command,
|
|
722
|
+
"sidechat.state": state,
|
|
723
|
+
"sidechat.dispose": dispose,
|
|
724
|
+
"settings.get": () => {
|
|
725
|
+
return getSettings()?.get() ?? {
|
|
726
|
+
value: void 0,
|
|
727
|
+
revision: void 0
|
|
728
|
+
};
|
|
729
|
+
},
|
|
730
|
+
"settings.update": async (payload) => {
|
|
731
|
+
const settings = getSettings();
|
|
732
|
+
if (settings === void 0) throw new SidechatError("settings-rejected", "the settings service is not mounted in this deployment", 503);
|
|
733
|
+
const record = payload;
|
|
734
|
+
const patch = record?.patch;
|
|
735
|
+
if (patch === null || typeof patch !== "object" || Array.isArray(patch)) throw new SidechatError("bad-request", "patch must be a plain object");
|
|
736
|
+
const expectedRevision = typeof record?.expectedRevision === "number" ? record.expectedRevision : void 0;
|
|
737
|
+
try {
|
|
738
|
+
return await settings.update(patch, expectedRevision);
|
|
739
|
+
} catch (error) {
|
|
740
|
+
if (error instanceof SettingsConflictError) throw new SidechatError("settings-conflict", error.message, 409);
|
|
741
|
+
throw new SidechatError("settings-rejected", error instanceof Error ? error.message : String(error), 400);
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
/** Read the connection row's trustedHosts, or empty (loopback-only fence). */
|
|
747
|
+
function trustedHostsOf(ctx) {
|
|
748
|
+
const loader = ctx.get("loader");
|
|
749
|
+
for (const entry of loader?.entries?.() ?? []) if (entry.options.name === "connection") return entry.options.config?.trustedHosts ?? [];
|
|
750
|
+
return [];
|
|
751
|
+
}
|
|
752
|
+
/** Host plugin body: register the /sidechat JSON API routes. */
|
|
753
|
+
function apply(ctx) {
|
|
754
|
+
const sideChats = /* @__PURE__ */ new Map();
|
|
755
|
+
let settingsFace;
|
|
756
|
+
ctx.inject(["settings"], (sctx) => {
|
|
757
|
+
const ns = SUBCHAT_PREFS_NS;
|
|
758
|
+
sctx.settings.register(ns, PrefsSchema);
|
|
759
|
+
const viewOf = () => {
|
|
760
|
+
const descriptor = sctx.settings.describe({ redactSecrets: true }).find((c) => c.ns === ns);
|
|
761
|
+
return descriptor === void 0 ? {
|
|
762
|
+
value: void 0,
|
|
763
|
+
revision: void 0
|
|
764
|
+
} : {
|
|
765
|
+
value: descriptor.value,
|
|
766
|
+
revision: descriptor.revision
|
|
767
|
+
};
|
|
768
|
+
};
|
|
769
|
+
settingsFace = {
|
|
770
|
+
get: viewOf,
|
|
771
|
+
update: async (patch, expectedRevision) => {
|
|
772
|
+
await sctx.settings.update(ns, patch, expectedRevision);
|
|
773
|
+
return viewOf();
|
|
774
|
+
}
|
|
775
|
+
};
|
|
776
|
+
});
|
|
777
|
+
const api = buildApi(ctx, sideChats, () => settingsFace);
|
|
778
|
+
ctx.effect(() => {
|
|
779
|
+
return () => {
|
|
780
|
+
for (const record of sideChats.values()) record.handle.dispose().catch(() => {});
|
|
781
|
+
sideChats.clear();
|
|
782
|
+
};
|
|
783
|
+
}, "dsh-side-chat: dispose side chats");
|
|
784
|
+
ctx.effect(() => ctx.webServer.register({
|
|
785
|
+
kind: "prefix",
|
|
786
|
+
path: "/sidechat/api",
|
|
787
|
+
handler: async (req, res) => {
|
|
788
|
+
if (!isTrustedApiRequest(req, trustedHostsOf(ctx))) {
|
|
789
|
+
writeJson(res, 403, {
|
|
790
|
+
ok: false,
|
|
791
|
+
error: {
|
|
792
|
+
code: "forbidden",
|
|
793
|
+
message: "forbidden"
|
|
794
|
+
}
|
|
795
|
+
});
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
if (req.method !== "POST") {
|
|
799
|
+
writeJson(res, 405, {
|
|
800
|
+
ok: false,
|
|
801
|
+
error: {
|
|
802
|
+
code: "method-error",
|
|
803
|
+
message: "method not allowed"
|
|
804
|
+
}
|
|
805
|
+
});
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
const pathname = new URL(req.url ?? "/", "http://dsh.internal").pathname;
|
|
809
|
+
const method = pathname.startsWith("/sidechat/api/") ? pathname.slice(14) : void 0;
|
|
810
|
+
if (method === void 0 || method.includes("/")) {
|
|
811
|
+
writeJson(res, 404, {
|
|
812
|
+
ok: false,
|
|
813
|
+
error: {
|
|
814
|
+
code: "not-found",
|
|
815
|
+
message: "unknown sidechat API method"
|
|
816
|
+
}
|
|
817
|
+
});
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
const handler = api[method];
|
|
821
|
+
if (handler === void 0) {
|
|
822
|
+
writeJson(res, 404, {
|
|
823
|
+
ok: false,
|
|
824
|
+
error: {
|
|
825
|
+
code: "not-found",
|
|
826
|
+
message: `unknown sidechat API method "${method}"`
|
|
827
|
+
}
|
|
828
|
+
});
|
|
829
|
+
return;
|
|
830
|
+
}
|
|
831
|
+
try {
|
|
832
|
+
writeOk(res, await handler(await readJsonBody(req)));
|
|
833
|
+
} catch (error) {
|
|
834
|
+
writeError(res, error);
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}), "dsh-side-chat: /sidechat/api routes");
|
|
838
|
+
}
|
|
839
|
+
//#endregion
|
|
840
|
+
export { apply, inject, name };
|