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.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +317 -0
  3. package/README.zh.md +261 -0
  4. package/cordis.patch.yml +8 -0
  5. package/dsh.plugin.json +16 -0
  6. package/lib/client-registry.js +2949 -0
  7. package/lib/client-registry.js.map +1 -0
  8. package/lib/client.js +2949 -0
  9. package/lib/client.js.map +1 -0
  10. package/lib/index.js +840 -0
  11. package/lib/types/client/api.d.ts +218 -0
  12. package/lib/types/client/attachments/AttachmentRail.d.ts +39 -0
  13. package/lib/types/client/attachments/DropOverlay.d.ts +18 -0
  14. package/lib/types/client/attachments/ImageLightbox.d.ts +20 -0
  15. package/lib/types/client/attachments/MessageImage.d.ts +38 -0
  16. package/lib/types/client/attachments/index.d.ts +18 -0
  17. package/lib/types/client/index.d.ts +6 -0
  18. package/lib/types/client/locales.d.ts +178 -0
  19. package/lib/types/context-types.d.ts +390 -0
  20. package/lib/types/index.d.ts +7 -0
  21. package/lib/types/settings-shared.d.ts +24 -0
  22. package/lib/types/trust-fence.d.ts +20 -0
  23. package/lib/types/wire.d.ts +25 -0
  24. package/package.json +114 -0
  25. package/src/client/api.ts +112 -0
  26. package/src/client/attachments/AttachmentRail.module.css +89 -0
  27. package/src/client/attachments/AttachmentRail.tsx +173 -0
  28. package/src/client/attachments/DropOverlay.module.css +38 -0
  29. package/src/client/attachments/DropOverlay.tsx +62 -0
  30. package/src/client/attachments/ImageLightbox.module.css +44 -0
  31. package/src/client/attachments/ImageLightbox.tsx +58 -0
  32. package/src/client/attachments/MessageImage.module.css +61 -0
  33. package/src/client/attachments/MessageImage.tsx +120 -0
  34. package/src/client/attachments/index.ts +19 -0
  35. package/src/client/client.module.css +1032 -0
  36. package/src/client/index.tsx +1966 -0
  37. package/src/client/layout.css +16 -0
  38. package/src/client/locales.ts +181 -0
  39. package/src/context-types.ts +384 -0
  40. package/src/css-modules.d.ts +10 -0
  41. package/src/index.ts +840 -0
  42. package/src/settings-shared.ts +33 -0
  43. package/src/trust-fence.ts +70 -0
  44. 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 };