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