@jaychang1989/dsh-webchat 0.3.0

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 (45) hide show
  1. package/LICENSE +201 -0
  2. package/NOTICE +18 -0
  3. package/README.md +78 -0
  4. package/README.zh.md +83 -0
  5. package/cordis.patch.yml +22 -0
  6. package/lib/client.js +1725 -0
  7. package/lib/client.js.map +1 -0
  8. package/lib/index.js +3887 -0
  9. package/lib/types/client/api.d.ts +74 -0
  10. package/lib/types/client/controller.d.ts +19 -0
  11. package/lib/types/client/index.d.ts +30 -0
  12. package/lib/types/client/locales.d.ts +169 -0
  13. package/lib/types/client/mount.d.ts +23 -0
  14. package/lib/types/client/panel/Markdown.d.ts +15 -0
  15. package/lib/types/client/panel/WebChatPanel.d.ts +28 -0
  16. package/lib/types/client/sidebar-entry-core.d.ts +60 -0
  17. package/lib/types/client/sidebar-entry.d.ts +22 -0
  18. package/lib/types/engine/engine.d.ts +247 -0
  19. package/lib/types/engine/html-md.d.ts +23 -0
  20. package/lib/types/index.d.ts +63 -0
  21. package/lib/types/protocol.d.ts +107 -0
  22. package/lib/types/routes.d.ts +22 -0
  23. package/lib/types/store.d.ts +69 -0
  24. package/lib/types/tools.d.ts +27 -0
  25. package/lib/types/transfer.d.ts +138 -0
  26. package/package.json +96 -0
  27. package/src/client/api.ts +90 -0
  28. package/src/client/controller.ts +43 -0
  29. package/src/client/css-modules.d.ts +5 -0
  30. package/src/client/index.ts +75 -0
  31. package/src/client/locales.ts +172 -0
  32. package/src/client/mount.tsx +125 -0
  33. package/src/client/panel/Markdown.tsx +251 -0
  34. package/src/client/panel/WebChatPanel.tsx +760 -0
  35. package/src/client/panel/panel.module.css +936 -0
  36. package/src/client/sidebar-entry-core.ts +207 -0
  37. package/src/client/sidebar-entry.ts +49 -0
  38. package/src/engine/engine.ts +1385 -0
  39. package/src/engine/html-md.ts +126 -0
  40. package/src/index.ts +212 -0
  41. package/src/protocol.ts +116 -0
  42. package/src/routes.ts +307 -0
  43. package/src/store.ts +217 -0
  44. package/src/tools.ts +257 -0
  45. package/src/transfer.ts +602 -0
package/lib/index.js ADDED
@@ -0,0 +1,3887 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import { randomUUID } from "node:crypto";
4
+ import { chromium } from "playwright-core";
5
+ import { realpath } from "node:fs/promises";
6
+ import { BlockAssembler, createUserMessage } from "@deepseek-ai/dsh-llm";
7
+ import { SESSION_FORMAT_VERSION, SessionId } from "@deepseek-ai/dsh-session";
8
+ import { defineTool } from "@deepseek-ai/dsh-tools";
9
+ //#region \0rolldown/runtime.js
10
+ var __create = Object.create;
11
+ var __defProp = Object.defineProperty;
12
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
13
+ var __getOwnPropNames = Object.getOwnPropertyNames;
14
+ var __getProtoOf = Object.getPrototypeOf;
15
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
16
+ var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
17
+ var __copyProps = (to, from, except, desc) => {
18
+ if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
19
+ key = keys[i];
20
+ if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
21
+ get: ((k) => from[k]).bind(null, key),
22
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
23
+ });
24
+ }
25
+ return to;
26
+ };
27
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
28
+ value: mod,
29
+ enumerable: true
30
+ }) : target, mod));
31
+ //#endregion
32
+ //#region node_modules/.pnpm/cosmokit@1.8.1/node_modules/cosmokit/lib/index.cjs
33
+ var require_lib$1 = /* @__PURE__ */ __commonJSMin(((exports, module) => {
34
+ var __defProp = Object.defineProperty;
35
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
36
+ var __getOwnPropNames = Object.getOwnPropertyNames;
37
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
38
+ var __export = (target, all) => {
39
+ for (var name in all) __defProp(target, name, {
40
+ get: all[name],
41
+ enumerable: true
42
+ });
43
+ };
44
+ var __copyProps = (to, from, except, desc) => {
45
+ if (from && typeof from === "object" || typeof from === "function") {
46
+ for (let key of __getOwnPropNames(from)) if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, {
47
+ get: () => from[key],
48
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
49
+ });
50
+ }
51
+ return to;
52
+ };
53
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
54
+ var index_exports = {};
55
+ __export(index_exports, {
56
+ Binary: () => Binary,
57
+ Time: () => Time,
58
+ arrayBufferToBase64: () => arrayBufferToBase64,
59
+ arrayBufferToHex: () => arrayBufferToHex,
60
+ base64ToArrayBuffer: () => base64ToArrayBuffer,
61
+ camelCase: () => camelCase,
62
+ camelize: () => camelize,
63
+ capitalize: () => capitalize,
64
+ clone: () => clone,
65
+ contain: () => contain,
66
+ deduplicate: () => deduplicate,
67
+ deepEqual: () => deepEqual,
68
+ defineProperty: () => defineProperty,
69
+ difference: () => difference,
70
+ filterKeys: () => filterKeys,
71
+ formatProperty: () => formatProperty,
72
+ hexToArrayBuffer: () => hexToArrayBuffer,
73
+ hyphenate: () => hyphenate,
74
+ intersection: () => intersection,
75
+ is: () => is,
76
+ isNonNullable: () => isNonNullable,
77
+ isNullable: () => isNullable,
78
+ isPlainObject: () => isPlainObject,
79
+ makeArray: () => makeArray,
80
+ mapValues: () => mapValues,
81
+ noop: () => noop,
82
+ omit: () => omit,
83
+ paramCase: () => paramCase,
84
+ pick: () => pick,
85
+ remove: () => remove,
86
+ sanitize: () => sanitize,
87
+ snakeCase: () => snakeCase,
88
+ trimSlash: () => trimSlash,
89
+ uncapitalize: () => uncapitalize,
90
+ union: () => union,
91
+ valueMap: () => mapValues
92
+ });
93
+ module.exports = __toCommonJS(index_exports);
94
+ function noop() {}
95
+ function isNullable(value) {
96
+ return value === null || value === void 0;
97
+ }
98
+ function isNonNullable(value) {
99
+ return !isNullable(value);
100
+ }
101
+ function isPlainObject(data) {
102
+ return data && typeof data === "object" && !Array.isArray(data);
103
+ }
104
+ function filterKeys(object, filter) {
105
+ return Object.fromEntries(Object.entries(object).filter(([key, value]) => filter(key, value)));
106
+ }
107
+ function mapValues(object, transform) {
108
+ return Object.fromEntries(Object.entries(object).map(([key, value]) => [key, transform(value, key)]));
109
+ }
110
+ function pick(source, keys, forced) {
111
+ if (!keys) return { ...source };
112
+ const result = {};
113
+ for (const key of keys) if (forced || source[key] !== void 0) result[key] = source[key];
114
+ return result;
115
+ }
116
+ function omit(source, keys) {
117
+ if (!keys) return { ...source };
118
+ const result = { ...source };
119
+ for (const key of keys) Reflect.deleteProperty(result, key);
120
+ return result;
121
+ }
122
+ function defineProperty(object, key, value) {
123
+ return Object.defineProperty(object, key, {
124
+ writable: true,
125
+ value,
126
+ enumerable: false
127
+ });
128
+ }
129
+ function contain(array1, array2) {
130
+ return array2.every((item) => array1.includes(item));
131
+ }
132
+ function intersection(array1, array2) {
133
+ return array1.filter((item) => array2.includes(item));
134
+ }
135
+ function difference(array1, array2) {
136
+ return array1.filter((item) => !array2.includes(item));
137
+ }
138
+ function union(array1, array2) {
139
+ return Array.from(/* @__PURE__ */ new Set([...array1, ...array2]));
140
+ }
141
+ function deduplicate(array) {
142
+ return [...new Set(array)];
143
+ }
144
+ function remove(list, item) {
145
+ const index = list?.indexOf(item);
146
+ if (index >= 0) {
147
+ list.splice(index, 1);
148
+ return true;
149
+ } else return false;
150
+ }
151
+ function makeArray(source) {
152
+ return Array.isArray(source) ? source : isNullable(source) ? [] : [source];
153
+ }
154
+ function is(type, value) {
155
+ if (arguments.length === 1) return (value2) => is(type, value2);
156
+ return type in globalThis && value instanceof globalThis[type] || Object.prototype.toString.call(value).slice(8, -1) === type;
157
+ }
158
+ function isArrayBufferLike(value) {
159
+ return is("ArrayBuffer", value) || is("SharedArrayBuffer", value);
160
+ }
161
+ function isArrayBufferSource(value) {
162
+ return isArrayBufferLike(value) || ArrayBuffer.isView(value);
163
+ }
164
+ var Binary;
165
+ ((Binary2) => {
166
+ Binary2.is = isArrayBufferLike;
167
+ Binary2.isSource = isArrayBufferSource;
168
+ function fromSource(source) {
169
+ if (ArrayBuffer.isView(source)) return source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength);
170
+ else return source;
171
+ }
172
+ Binary2.fromSource = fromSource;
173
+ function toBase64(source) {
174
+ source = fromSource(source);
175
+ if (typeof Buffer !== "undefined") return Buffer.from(source).toString("base64");
176
+ let binary = "";
177
+ const bytes = new Uint8Array(source);
178
+ for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
179
+ return btoa(binary);
180
+ }
181
+ Binary2.toBase64 = toBase64;
182
+ function fromBase64(source) {
183
+ if (typeof Buffer !== "undefined") return fromSource(Buffer.from(source, "base64"));
184
+ return Uint8Array.from(atob(source), (c) => c.charCodeAt(0));
185
+ }
186
+ Binary2.fromBase64 = fromBase64;
187
+ function toHex(source) {
188
+ source = fromSource(source);
189
+ if (typeof Buffer !== "undefined") return Buffer.from(source).toString("hex");
190
+ return Array.from(new Uint8Array(source), (byte) => byte.toString(16).padStart(2, "0")).join("");
191
+ }
192
+ Binary2.toHex = toHex;
193
+ function fromHex(source) {
194
+ if (typeof Buffer !== "undefined") return fromSource(Buffer.from(source, "hex"));
195
+ const hex = source.length % 2 === 0 ? source : source.slice(0, source.length - 1);
196
+ const buffer = [];
197
+ for (let i = 0; i < hex.length; i += 2) buffer.push(parseInt(`${hex[i]}${hex[i + 1]}`, 16));
198
+ return Uint8Array.from(buffer).buffer;
199
+ }
200
+ Binary2.fromHex = fromHex;
201
+ })(Binary || (Binary = {}));
202
+ var base64ToArrayBuffer = Binary.fromBase64;
203
+ var arrayBufferToBase64 = Binary.toBase64;
204
+ var hexToArrayBuffer = Binary.fromHex;
205
+ var arrayBufferToHex = Binary.toHex;
206
+ function clone(source, refs = /* @__PURE__ */ new Map()) {
207
+ if (!source || typeof source !== "object") return source;
208
+ if (is("Date", source)) return new Date(source.valueOf());
209
+ if (is("RegExp", source)) return new RegExp(source.source, source.flags);
210
+ if (isArrayBufferLike(source)) return source.slice(0);
211
+ if (ArrayBuffer.isView(source)) return source.buffer.slice(source.byteOffset, source.byteOffset + source.byteLength);
212
+ const cached = refs.get(source);
213
+ if (cached) return cached;
214
+ if (Array.isArray(source)) {
215
+ const result2 = [];
216
+ refs.set(source, result2);
217
+ source.forEach((value, index) => {
218
+ result2[index] = Reflect.apply(clone, null, [value, refs]);
219
+ });
220
+ return result2;
221
+ }
222
+ const result = Object.create(Object.getPrototypeOf(source));
223
+ refs.set(source, result);
224
+ for (const key of Reflect.ownKeys(source)) {
225
+ const descriptor = { ...Reflect.getOwnPropertyDescriptor(source, key) };
226
+ if ("value" in descriptor) descriptor.value = Reflect.apply(clone, null, [descriptor.value, refs]);
227
+ Reflect.defineProperty(result, key, descriptor);
228
+ }
229
+ return result;
230
+ }
231
+ function deepEqual(a, b, strict) {
232
+ if (a === b) return true;
233
+ if (!strict && isNullable(a) && isNullable(b)) return true;
234
+ if (typeof a !== typeof b) return false;
235
+ if (typeof a !== "object") return false;
236
+ if (!a || !b) return false;
237
+ function check(test, then) {
238
+ return test(a) ? test(b) ? then(a, b) : false : test(b) ? false : void 0;
239
+ }
240
+ return check(Array.isArray, (a2, b2) => a2.length === b2.length && a2.every((item, index) => deepEqual(item, b2[index]))) ?? check(is("Date"), (a2, b2) => a2.valueOf() === b2.valueOf()) ?? check(is("RegExp"), (a2, b2) => a2.source === b2.source && a2.flags === b2.flags) ?? check(isArrayBufferLike, (a2, b2) => {
241
+ if (a2.byteLength !== b2.byteLength) return false;
242
+ const viewA = new Uint8Array(a2);
243
+ const viewB = new Uint8Array(b2);
244
+ for (let i = 0; i < viewA.length; i++) if (viewA[i] !== viewB[i]) return false;
245
+ return true;
246
+ }) ?? Object.keys({
247
+ ...a,
248
+ ...b
249
+ }).every((key) => deepEqual(a[key], b[key], strict));
250
+ }
251
+ function capitalize(source) {
252
+ return source.charAt(0).toUpperCase() + source.slice(1);
253
+ }
254
+ function uncapitalize(source) {
255
+ return source.charAt(0).toLowerCase() + source.slice(1);
256
+ }
257
+ function camelCase(source) {
258
+ return source.replace(/[_-][a-z]/g, (str) => str.slice(1).toUpperCase());
259
+ }
260
+ function tokenize(source, delimiters, delimiter) {
261
+ const output = [];
262
+ let state = 0;
263
+ for (let i = 0; i < source.length; i++) {
264
+ const code = source.charCodeAt(i);
265
+ if (code >= 65 && code <= 90) {
266
+ if (state === 1) {
267
+ const next = source.charCodeAt(i + 1);
268
+ if (next >= 97 && next <= 122) output.push(delimiter);
269
+ output.push(code + 32);
270
+ } else {
271
+ if (state !== 0) output.push(delimiter);
272
+ output.push(code + 32);
273
+ }
274
+ state = 1;
275
+ } else if (code >= 97 && code <= 122) {
276
+ output.push(code);
277
+ state = 2;
278
+ } else if (delimiters.includes(code)) {
279
+ if (state !== 0) output.push(delimiter);
280
+ state = 0;
281
+ } else output.push(code);
282
+ }
283
+ return String.fromCharCode(...output);
284
+ }
285
+ function paramCase(source) {
286
+ return tokenize(source, [45, 95], 45);
287
+ }
288
+ function snakeCase(source) {
289
+ return tokenize(source, [45, 95], 95);
290
+ }
291
+ var camelize = camelCase;
292
+ var hyphenate = paramCase;
293
+ function formatProperty(key) {
294
+ if (typeof key !== "string") return `[${key.toString()}]`;
295
+ return /^[a-z_$][\w$]*$/i.test(key) ? `.${key}` : `[${JSON.stringify(key)}]`;
296
+ }
297
+ function trimSlash(source) {
298
+ return source.replace(/\/$/, "");
299
+ }
300
+ function sanitize(source) {
301
+ if (!source.startsWith("/")) source = "/" + source;
302
+ return trimSlash(source);
303
+ }
304
+ var Time;
305
+ ((Time2) => {
306
+ Time2.millisecond = 1;
307
+ Time2.second = 1e3;
308
+ Time2.minute = Time2.second * 60;
309
+ Time2.hour = Time2.minute * 60;
310
+ Time2.day = Time2.hour * 24;
311
+ Time2.week = Time2.day * 7;
312
+ let timezoneOffset = (/* @__PURE__ */ new Date()).getTimezoneOffset();
313
+ function setTimezoneOffset(offset) {
314
+ timezoneOffset = offset;
315
+ }
316
+ Time2.setTimezoneOffset = setTimezoneOffset;
317
+ function getTimezoneOffset() {
318
+ return timezoneOffset;
319
+ }
320
+ Time2.getTimezoneOffset = getTimezoneOffset;
321
+ function getDateNumber(date = /* @__PURE__ */ new Date(), offset) {
322
+ if (typeof date === "number") date = new Date(date);
323
+ if (offset === void 0) offset = timezoneOffset;
324
+ return Math.floor((date.valueOf() / Time2.minute - offset) / 1440);
325
+ }
326
+ Time2.getDateNumber = getDateNumber;
327
+ function fromDateNumber(value, offset) {
328
+ const date = new Date(value * Time2.day);
329
+ if (offset === void 0) offset = timezoneOffset;
330
+ return new Date(+date + offset * Time2.minute);
331
+ }
332
+ Time2.fromDateNumber = fromDateNumber;
333
+ const numeric = /\d+(?:\.\d+)?/.source;
334
+ const timeRegExp = new RegExp(`^${[
335
+ "w(?:eek(?:s)?)?",
336
+ "d(?:ay(?:s)?)?",
337
+ "h(?:our(?:s)?)?",
338
+ "m(?:in(?:ute)?(?:s)?)?",
339
+ "s(?:ec(?:ond)?(?:s)?)?"
340
+ ].map((unit) => `(${numeric}${unit})?`).join("")}$`);
341
+ function parseTime(source) {
342
+ const capture = timeRegExp.exec(source);
343
+ if (!capture) return 0;
344
+ return (parseFloat(capture[1]) * Time2.week || 0) + (parseFloat(capture[2]) * Time2.day || 0) + (parseFloat(capture[3]) * Time2.hour || 0) + (parseFloat(capture[4]) * Time2.minute || 0) + (parseFloat(capture[5]) * Time2.second || 0);
345
+ }
346
+ Time2.parseTime = parseTime;
347
+ function parseDate(date) {
348
+ const parsed = parseTime(date);
349
+ if (parsed) date = Date.now() + parsed;
350
+ else if (/^\d{1,2}(:\d{1,2}){1,2}$/.test(date)) date = `${(/* @__PURE__ */ new Date()).toLocaleDateString()}-${date}`;
351
+ else if (/^\d{1,2}-\d{1,2}-\d{1,2}(:\d{1,2}){1,2}$/.test(date)) date = `${(/* @__PURE__ */ new Date()).getFullYear()}-${date}`;
352
+ return date ? new Date(date) : /* @__PURE__ */ new Date();
353
+ }
354
+ Time2.parseDate = parseDate;
355
+ function format(ms) {
356
+ const abs = Math.abs(ms);
357
+ if (abs >= Time2.day - Time2.hour / 2) return Math.round(ms / Time2.day) + "d";
358
+ else if (abs >= Time2.hour - Time2.minute / 2) return Math.round(ms / Time2.hour) + "h";
359
+ else if (abs >= Time2.minute - Time2.second / 2) return Math.round(ms / Time2.minute) + "m";
360
+ else if (abs >= Time2.second) return Math.round(ms / Time2.second) + "s";
361
+ return ms + "ms";
362
+ }
363
+ Time2.format = format;
364
+ function toDigits(source, length = 2) {
365
+ return source.toString().padStart(length, "0");
366
+ }
367
+ Time2.toDigits = toDigits;
368
+ function template(template2, time = /* @__PURE__ */ new Date()) {
369
+ return template2.replace("yyyy", time.getFullYear().toString()).replace("yy", time.getFullYear().toString().slice(2)).replace("MM", toDigits(time.getMonth() + 1)).replace("dd", toDigits(time.getDate())).replace("hh", toDigits(time.getHours())).replace("mm", toDigits(time.getMinutes())).replace("ss", toDigits(time.getSeconds())).replace("SSS", toDigits(time.getMilliseconds(), 3));
370
+ }
371
+ Time2.template = template;
372
+ })(Time || (Time = {}));
373
+ 0 && (module.exports = {
374
+ Binary,
375
+ Time,
376
+ arrayBufferToBase64,
377
+ arrayBufferToHex,
378
+ base64ToArrayBuffer,
379
+ camelCase,
380
+ camelize,
381
+ capitalize,
382
+ clone,
383
+ contain,
384
+ deduplicate,
385
+ deepEqual,
386
+ defineProperty,
387
+ difference,
388
+ filterKeys,
389
+ formatProperty,
390
+ hexToArrayBuffer,
391
+ hyphenate,
392
+ intersection,
393
+ is,
394
+ isNonNullable,
395
+ isNullable,
396
+ isPlainObject,
397
+ makeArray,
398
+ mapValues,
399
+ noop,
400
+ omit,
401
+ paramCase,
402
+ pick,
403
+ remove,
404
+ sanitize,
405
+ snakeCase,
406
+ trimSlash,
407
+ uncapitalize,
408
+ union,
409
+ valueMap
410
+ });
411
+ }));
412
+ //#endregion
413
+ //#region src/engine/html-md.ts
414
+ var import_lib = /* @__PURE__ */ __toESM((/* @__PURE__ */ __commonJSMin(((exports, module) => {
415
+ var __defProp = Object.defineProperty;
416
+ var __name = (target, value) => __defProp(target, "name", {
417
+ value,
418
+ configurable: true
419
+ });
420
+ var import_cosmokit = require_lib$1();
421
+ var kSchema = Symbol.for("schemastery");
422
+ var kValidationError = Symbol.for("ValidationError");
423
+ globalThis.__schemastery_index__ ??= 0;
424
+ globalThis.__schemastery_refs__ = void 0;
425
+ var ValidationError = class extends TypeError {
426
+ constructor(message, options) {
427
+ let prefix = "$";
428
+ for (const segment of options.path || []) if (typeof segment === "string") prefix += "." + segment;
429
+ else if (typeof segment === "number") prefix += "[" + segment + "]";
430
+ else if (typeof segment === "symbol") prefix += `[Symbol(${segment.toString()})]`;
431
+ if (prefix.startsWith(".")) prefix = prefix.slice(1);
432
+ super((prefix === "$" ? "" : `${prefix} `) + message);
433
+ this.options = options;
434
+ }
435
+ static {
436
+ __name(this, "ValidationError");
437
+ }
438
+ name = "ValidationError";
439
+ static is(error) {
440
+ return !!error?.[kValidationError];
441
+ }
442
+ };
443
+ Object.defineProperty(ValidationError.prototype, kValidationError, { value: true });
444
+ var Schema = /* @__PURE__ */ __name(function(options) {
445
+ const schema = /* @__PURE__ */ __name(function(data, options2 = {}) {
446
+ return Schema.resolve(data, schema, options2)[0];
447
+ }, "schema");
448
+ if (options.refs) {
449
+ const refs = (0, import_cosmokit.valueMap)(options.refs, (options2) => new Schema(options2));
450
+ const getRef = /* @__PURE__ */ __name((uid) => refs[uid], "getRef");
451
+ for (const key in refs) {
452
+ const options2 = refs[key];
453
+ options2.sKey = getRef(options2.sKey);
454
+ options2.inner = getRef(options2.inner);
455
+ options2.list = options2.list && options2.list.map(getRef);
456
+ options2.dict = options2.dict && (0, import_cosmokit.valueMap)(options2.dict, getRef);
457
+ }
458
+ return refs[options.uid];
459
+ }
460
+ Object.assign(schema, options);
461
+ if (typeof schema.callback === "string") try {
462
+ schema.callback = new Function("return " + schema.callback)();
463
+ } catch {}
464
+ Object.defineProperty(schema, "uid", { value: globalThis.__schemastery_index__++ });
465
+ Object.setPrototypeOf(schema, Schema.prototype);
466
+ schema.meta ||= {};
467
+ schema.toString = schema.toString.bind(schema);
468
+ return schema;
469
+ }, "Schema");
470
+ Schema.prototype = Object.create(Function.prototype);
471
+ Schema.prototype[kSchema] = true;
472
+ Object.defineProperty(Schema.prototype, "~standard", { get() {
473
+ return {
474
+ version: 1,
475
+ vendor: "schemastery",
476
+ validate: /* @__PURE__ */ __name((value) => {
477
+ try {
478
+ return { value: Schema.resolve(value, this, {})[0] };
479
+ } catch (error) {
480
+ if (ValidationError.is(error)) return { issues: [{
481
+ message: error.message,
482
+ path: error.options.path
483
+ }] };
484
+ throw error;
485
+ }
486
+ }, "validate")
487
+ };
488
+ } });
489
+ Schema.ValidationError = ValidationError;
490
+ Schema.prototype.toJSON = /* @__PURE__ */ __name(function toJSON() {
491
+ if (globalThis.__schemastery_refs__) {
492
+ globalThis.__schemastery_refs__[this.uid] ??= JSON.parse(JSON.stringify({ ...this }));
493
+ return this.uid;
494
+ }
495
+ globalThis.__schemastery_refs__ = { [this.uid]: { ...this } };
496
+ globalThis.__schemastery_refs__[this.uid] = JSON.parse(JSON.stringify({ ...this }));
497
+ const result = {
498
+ uid: this.uid,
499
+ refs: globalThis.__schemastery_refs__
500
+ };
501
+ globalThis.__schemastery_refs__ = void 0;
502
+ return result;
503
+ }, "toJSON");
504
+ Schema.prototype.set = /* @__PURE__ */ __name(function set(key, value) {
505
+ this.dict[key] = value;
506
+ return this;
507
+ }, "set");
508
+ Schema.prototype.push = /* @__PURE__ */ __name(function push(value) {
509
+ this.list.push(value);
510
+ return this;
511
+ }, "push");
512
+ function mergeDesc(original, messages) {
513
+ const result = typeof original === "string" ? { "": original } : { ...original };
514
+ for (const locale in messages) {
515
+ const value = messages[locale];
516
+ if (value?.$description || value?.$desc) result[locale] = value.$description || value.$desc;
517
+ else if (typeof value === "string") result[locale] = value;
518
+ }
519
+ return result;
520
+ }
521
+ __name(mergeDesc, "mergeDesc");
522
+ function getInner(value) {
523
+ return value?.$value ?? value?.$inner;
524
+ }
525
+ __name(getInner, "getInner");
526
+ function extractKeys(data) {
527
+ return (0, import_cosmokit.filterKeys)(data ?? {}, (key) => !key.startsWith("$"));
528
+ }
529
+ __name(extractKeys, "extractKeys");
530
+ Schema.prototype.i18n = /* @__PURE__ */ __name(function i18n(messages) {
531
+ const schema = Schema(this);
532
+ const desc = mergeDesc(schema.meta.description, messages);
533
+ if (Object.keys(desc).length) schema.meta.description = desc;
534
+ if (schema.dict) schema.dict = (0, import_cosmokit.valueMap)(schema.dict, (inner, key) => {
535
+ return inner.i18n((0, import_cosmokit.valueMap)(messages, (data) => getInner(data)?.[key] ?? data?.[key]));
536
+ });
537
+ if (schema.list) schema.list = schema.list.map((inner, index) => {
538
+ return inner.i18n((0, import_cosmokit.valueMap)(messages, (data = {}) => {
539
+ if (Array.isArray(getInner(data))) return getInner(data)[index];
540
+ if (Array.isArray(data)) return data[index];
541
+ return extractKeys(data);
542
+ }));
543
+ });
544
+ if (schema.inner) schema.inner = schema.inner.i18n((0, import_cosmokit.valueMap)(messages, (data) => {
545
+ if (getInner(data)) return getInner(data);
546
+ return extractKeys(data);
547
+ }));
548
+ if (schema.sKey) schema.sKey = schema.sKey.i18n((0, import_cosmokit.valueMap)(messages, (data) => data?.$key));
549
+ return schema;
550
+ }, "i18n");
551
+ Schema.prototype.extra = /* @__PURE__ */ __name(function extra(key, value) {
552
+ const schema = Schema(this);
553
+ schema.meta = {
554
+ ...schema.meta,
555
+ [key]: value
556
+ };
557
+ return schema;
558
+ }, "extra");
559
+ for (const key of [
560
+ "required",
561
+ "disabled",
562
+ "collapse",
563
+ "hidden",
564
+ "loose"
565
+ ]) Object.assign(Schema.prototype, { [key](value = true) {
566
+ const schema = Schema(this);
567
+ schema.meta = {
568
+ ...schema.meta,
569
+ [key]: value
570
+ };
571
+ return schema;
572
+ } });
573
+ Schema.prototype.deprecated = /* @__PURE__ */ __name(function deprecated() {
574
+ const schema = Schema(this);
575
+ schema.meta.badges ||= [];
576
+ schema.meta.badges.push({
577
+ text: "deprecated",
578
+ type: "danger"
579
+ });
580
+ return schema;
581
+ }, "deprecated");
582
+ Schema.prototype.experimental = /* @__PURE__ */ __name(function experimental() {
583
+ const schema = Schema(this);
584
+ schema.meta.badges ||= [];
585
+ schema.meta.badges.push({
586
+ text: "experimental",
587
+ type: "warning"
588
+ });
589
+ return schema;
590
+ }, "experimental");
591
+ Schema.prototype.pattern = /* @__PURE__ */ __name(function pattern(regexp) {
592
+ const schema = Schema(this);
593
+ const pattern2 = (0, import_cosmokit.pick)(regexp, ["source", "flags"]);
594
+ schema.meta = {
595
+ ...schema.meta,
596
+ pattern: pattern2
597
+ };
598
+ return schema;
599
+ }, "pattern");
600
+ Schema.prototype.simplify = /* @__PURE__ */ __name(function simplify(value) {
601
+ if ((0, import_cosmokit.deepEqual)(value, this.meta.default, this.type === "dict")) return null;
602
+ if ((0, import_cosmokit.isNullable)(value)) return value;
603
+ if (this.type === "object" || this.type === "dict") {
604
+ const result = {};
605
+ for (const key in value) {
606
+ const item = (this.type === "object" ? this.dict[key] : this.inner)?.simplify(value[key]);
607
+ if (this.type === "dict" || !(0, import_cosmokit.isNullable)(item)) result[key] = item;
608
+ }
609
+ if ((0, import_cosmokit.deepEqual)(result, this.meta.default, this.type === "dict")) return null;
610
+ return result;
611
+ } else if (this.type === "array" || this.type === "tuple") {
612
+ const result = [];
613
+ value.forEach((value2, index) => {
614
+ const schema = this.type === "array" ? this.inner : this.list[index];
615
+ const item = schema ? schema.simplify(value2) : value2;
616
+ result.push(item);
617
+ });
618
+ return result;
619
+ } else if (this.type === "intersect") {
620
+ const result = {};
621
+ for (const item of this.list) Object.assign(result, item.simplify(value));
622
+ return result;
623
+ } else if (this.type === "union") for (const schema of this.list) try {
624
+ Schema.resolve(value, schema, {});
625
+ return schema.simplify(value);
626
+ } catch {}
627
+ return value;
628
+ }, "simplify");
629
+ Schema.prototype.toString = /* @__PURE__ */ __name(function toString(inline) {
630
+ return formatters[this.type]?.(this, inline) ?? `Schema<${this.type}>`;
631
+ }, "toString");
632
+ Schema.prototype.role = /* @__PURE__ */ __name(function role(role, extra2) {
633
+ const schema = Schema(this);
634
+ schema.meta = {
635
+ ...schema.meta,
636
+ role,
637
+ extra: extra2
638
+ };
639
+ return schema;
640
+ }, "role");
641
+ for (const key of [
642
+ "default",
643
+ "link",
644
+ "comment",
645
+ "description",
646
+ "max",
647
+ "min",
648
+ "step"
649
+ ]) Object.assign(Schema.prototype, { [key](value) {
650
+ const schema = Schema(this);
651
+ schema.meta = {
652
+ ...schema.meta,
653
+ [key]: value
654
+ };
655
+ return schema;
656
+ } });
657
+ var resolvers = {};
658
+ Schema.extend = /* @__PURE__ */ __name(function extend(type, resolve2) {
659
+ resolvers[type] = resolve2;
660
+ }, "extend");
661
+ Schema.resolve = /* @__PURE__ */ __name(function resolve(data, schema, options = {}, strict = false) {
662
+ if (!schema) return [data];
663
+ if (options.ignore?.(data, schema)) return [data];
664
+ if ((0, import_cosmokit.isNullable)(data) && schema.type !== "lazy") {
665
+ if (schema.meta.required) throw new ValidationError(`missing required value`, options);
666
+ let current = schema;
667
+ let fallback = schema.meta.default;
668
+ while (current?.type === "intersect" && (0, import_cosmokit.isNullable)(fallback)) {
669
+ current = current.list[0];
670
+ fallback = current?.meta.default;
671
+ }
672
+ if ((0, import_cosmokit.isNullable)(fallback)) return [data];
673
+ data = (0, import_cosmokit.clone)(fallback);
674
+ }
675
+ const callback = resolvers[schema.type];
676
+ if (!callback) throw new ValidationError(`unsupported type "${schema.type}"`, options);
677
+ try {
678
+ return callback(data, schema, options, strict);
679
+ } catch (error) {
680
+ if (!schema.meta.loose) throw error;
681
+ return [schema.meta.default];
682
+ }
683
+ }, "resolve");
684
+ Schema.from = /* @__PURE__ */ __name(function from(source) {
685
+ if ((0, import_cosmokit.isNullable)(source)) return Schema.any();
686
+ else if ([
687
+ "string",
688
+ "number",
689
+ "boolean"
690
+ ].includes(typeof source)) return Schema.const(source).required();
691
+ else if (source[kSchema]) return source;
692
+ else if (typeof source === "function") switch (source) {
693
+ case String: return Schema.string().required();
694
+ case Number: return Schema.number().required();
695
+ case Boolean: return Schema.boolean().required();
696
+ case Function: return Schema.function().required();
697
+ default: return Schema.is(source).required();
698
+ }
699
+ else throw new TypeError(`cannot infer schema from ${source}`);
700
+ }, "from");
701
+ Schema.lazy = /* @__PURE__ */ __name(function lazy(builder) {
702
+ const schema = new Schema({
703
+ type: "lazy",
704
+ builder,
705
+ inner: { toJSON: /* @__PURE__ */ __name(() => {
706
+ if (!schema.inner[kSchema]) {
707
+ schema.inner = schema.builder();
708
+ schema.inner.meta = {
709
+ ...schema.meta,
710
+ ...schema.inner.meta
711
+ };
712
+ }
713
+ return schema.inner.toJSON();
714
+ }, "toJSON") }
715
+ });
716
+ return schema;
717
+ }, "lazy");
718
+ Schema.natural = /* @__PURE__ */ __name(function natural() {
719
+ return Schema.number().step(1).min(0);
720
+ }, "natural");
721
+ Schema.percent = /* @__PURE__ */ __name(function percent() {
722
+ return Schema.number().step(.01).min(0).max(1).role("slider");
723
+ }, "percent");
724
+ Schema.date = /* @__PURE__ */ __name(function date() {
725
+ return Schema.union([Schema.is(Date), Schema.transform(Schema.string().role("datetime"), (value, options) => {
726
+ const date2 = new Date(value);
727
+ if (isNaN(+date2)) throw new ValidationError(`invalid date "${value}"`, options);
728
+ return date2;
729
+ }, true)]);
730
+ }, "date");
731
+ Schema.regExp = /* @__PURE__ */ __name(function regExp(flag = "") {
732
+ return Schema.union([Schema.is(RegExp), Schema.transform(Schema.string().role("regexp", { flag }), (value, options) => {
733
+ try {
734
+ return new RegExp(value, flag);
735
+ } catch (e) {
736
+ throw new ValidationError(e.message, options);
737
+ }
738
+ }, true)]);
739
+ }, "regExp");
740
+ Schema.arrayBuffer = /* @__PURE__ */ __name(function arrayBuffer(encoding) {
741
+ return Schema.union([
742
+ Schema.is(ArrayBuffer),
743
+ Schema.is(SharedArrayBuffer),
744
+ Schema.transform(Schema.any(), (value, options) => {
745
+ if (import_cosmokit.Binary.isSource(value)) return import_cosmokit.Binary.fromSource(value);
746
+ throw new ValidationError(`expected ArrayBufferSource but got ${value}`, options);
747
+ }, true),
748
+ ...encoding ? [Schema.transform(Schema.string(), (value, options) => {
749
+ try {
750
+ return encoding === "base64" ? import_cosmokit.Binary.fromBase64(value) : import_cosmokit.Binary.fromHex(value);
751
+ } catch (e) {
752
+ throw new ValidationError(e.message, options);
753
+ }
754
+ }, true)] : []
755
+ ]);
756
+ }, "arrayBuffer");
757
+ Schema.extend("lazy", (data, schema, options, strict) => {
758
+ if (!schema.inner[kSchema]) {
759
+ schema.inner = schema.builder();
760
+ schema.inner.meta = {
761
+ ...schema.meta,
762
+ ...schema.inner.meta
763
+ };
764
+ }
765
+ return Schema.resolve(data, schema.inner, options, strict);
766
+ });
767
+ Schema.extend("any", (data) => {
768
+ return [data];
769
+ });
770
+ Schema.extend("never", (data, _, options) => {
771
+ throw new ValidationError(`expected nullable but got ${data}`, options);
772
+ });
773
+ Schema.extend("const", (data, { value }, options) => {
774
+ if ((0, import_cosmokit.deepEqual)(data, value)) return [value];
775
+ throw new ValidationError(`expected ${value} but got ${data}`, options);
776
+ });
777
+ function checkWithinRange(data, meta, description, options, skipMin = false) {
778
+ const { max = Infinity, min = -Infinity } = meta;
779
+ if (data > max) throw new ValidationError(`expected ${description} <= ${max} but got ${data}`, options);
780
+ if (data < min && !skipMin) throw new ValidationError(`expected ${description} >= ${min} but got ${data}`, options);
781
+ }
782
+ __name(checkWithinRange, "checkWithinRange");
783
+ Schema.extend("string", (data, { meta }, options) => {
784
+ if (typeof data !== "string") throw new ValidationError(`expected string but got ${data}`, options);
785
+ if (meta.pattern) {
786
+ const regexp = new RegExp(meta.pattern.source, meta.pattern.flags);
787
+ if (!regexp.test(data)) throw new ValidationError(`expect string to match regexp ${regexp}`, options);
788
+ }
789
+ checkWithinRange(data.length, meta, "string length", options);
790
+ return [data];
791
+ });
792
+ function decimalShift(data, digits) {
793
+ const str = data.toString();
794
+ if (str.includes("e")) return data * Math.pow(10, digits);
795
+ const index = str.indexOf(".");
796
+ if (index === -1) return data * Math.pow(10, digits);
797
+ const frac = str.slice(index + 1);
798
+ const integer = str.slice(0, index);
799
+ if (frac.length <= digits) return +(integer + frac.padEnd(digits, "0"));
800
+ return +(integer + frac.slice(0, digits) + "." + frac.slice(digits));
801
+ }
802
+ __name(decimalShift, "decimalShift");
803
+ function isMultipleOf(data, min, step) {
804
+ step = Math.abs(step);
805
+ if (!/^\d+\.\d+$/.test(step.toString())) return (data - min) % step === 0;
806
+ const index = step.toString().indexOf(".");
807
+ const digits = step.toString().slice(index + 1).length;
808
+ return Math.abs(decimalShift(data, digits) - decimalShift(min, digits)) % decimalShift(step, digits) === 0;
809
+ }
810
+ __name(isMultipleOf, "isMultipleOf");
811
+ Schema.extend("number", (data, { meta }, options) => {
812
+ if (typeof data !== "number") throw new ValidationError(`expected number but got ${data}`, options);
813
+ checkWithinRange(data, meta, "number", options);
814
+ const { step } = meta;
815
+ if (step && !isMultipleOf(data, meta.min ?? 0, step)) throw new ValidationError(`expected number multiple of ${step} but got ${data}`, options);
816
+ return [data];
817
+ });
818
+ Schema.extend("boolean", (data, _, options) => {
819
+ if (typeof data === "boolean") return [data];
820
+ throw new ValidationError(`expected boolean but got ${data}`, options);
821
+ });
822
+ Schema.extend("bitset", (data, { bits, meta }, options) => {
823
+ let value = 0, keys = [];
824
+ if (typeof data === "number") {
825
+ value = data;
826
+ for (const key in bits) if (data & bits[key]) keys.push(key);
827
+ } else if (Array.isArray(data)) {
828
+ keys = data;
829
+ for (const key of keys) {
830
+ if (typeof key !== "string") throw new ValidationError(`expected string but got ${key}`, options);
831
+ if (key in bits) value |= bits[key];
832
+ }
833
+ } else throw new ValidationError(`expected number or array but got ${data}`, options);
834
+ if (value === meta.default) return [value];
835
+ return [value, keys];
836
+ });
837
+ Schema.extend("function", (data, _, options) => {
838
+ if (typeof data === "function") return [data];
839
+ throw new ValidationError(`expected function but got ${data}`, options);
840
+ });
841
+ Schema.extend("is", (data, { constructor }, options) => {
842
+ if (typeof constructor === "function") {
843
+ if (data instanceof constructor) return [data];
844
+ throw new ValidationError(`expected ${constructor.name} but got ${data}`, options);
845
+ } else {
846
+ if ((0, import_cosmokit.isNullable)(data)) throw new ValidationError(`expected ${constructor} but got ${data}`, options);
847
+ let prototype = Object.getPrototypeOf(data);
848
+ while (prototype) {
849
+ if (prototype.constructor?.name === constructor) return [data];
850
+ prototype = Object.getPrototypeOf(prototype);
851
+ }
852
+ throw new ValidationError(`expected ${constructor} but got ${data}`, options);
853
+ }
854
+ });
855
+ function property(data, key, schema, options) {
856
+ try {
857
+ const [value, adapted] = Schema.resolve(data[key], schema, {
858
+ ...options,
859
+ path: [...options.path || [], key]
860
+ });
861
+ if (adapted !== void 0) data[key] = adapted;
862
+ return value;
863
+ } catch (e) {
864
+ if (!options?.autofix) throw e;
865
+ delete data[key];
866
+ return schema.meta.default;
867
+ }
868
+ }
869
+ __name(property, "property");
870
+ Schema.extend("array", (data, { inner, meta }, options) => {
871
+ if (!Array.isArray(data)) throw new ValidationError(`expected array but got ${data}`, options);
872
+ checkWithinRange(data.length, meta, "array length", options, !(0, import_cosmokit.isNullable)(inner.meta.default));
873
+ return [data.map((_, index) => property(data, index, inner, options))];
874
+ });
875
+ Schema.extend("dict", (data, { inner, sKey }, options, strict) => {
876
+ if (!(0, import_cosmokit.isPlainObject)(data)) throw new ValidationError(`expected object but got ${data}`, options);
877
+ const result = {};
878
+ for (const key in data) {
879
+ let rKey;
880
+ try {
881
+ rKey = Schema.resolve(key, sKey, options)[0];
882
+ } catch (error) {
883
+ if (strict) continue;
884
+ throw error;
885
+ }
886
+ result[rKey] = property(data, key, inner, options);
887
+ data[rKey] = data[key];
888
+ if (key !== rKey) delete data[key];
889
+ }
890
+ return [result];
891
+ });
892
+ Schema.extend("tuple", (data, { list }, options, strict) => {
893
+ if (!Array.isArray(data)) throw new ValidationError(`expected array but got ${data}`, options);
894
+ const result = list.map((inner, index) => property(data, index, inner, options));
895
+ if (strict) return [result];
896
+ result.push(...data.slice(list.length));
897
+ return [result];
898
+ });
899
+ function merge(result, data) {
900
+ for (const key in data) {
901
+ if (key in result) continue;
902
+ result[key] = data[key];
903
+ }
904
+ }
905
+ __name(merge, "merge");
906
+ Schema.extend("object", (data, { dict }, options, strict) => {
907
+ if (!(0, import_cosmokit.isPlainObject)(data)) throw new ValidationError(`expected object but got ${data}`, options);
908
+ const result = {};
909
+ for (const key in dict) {
910
+ const value = property(data, key, dict[key], options);
911
+ if (!(0, import_cosmokit.isNullable)(value) || key in data) result[key] = value;
912
+ }
913
+ if (!strict) merge(result, data);
914
+ return [result];
915
+ });
916
+ Schema.extend("union", (data, { list, toString: toString2 }, options, strict) => {
917
+ const messages = [];
918
+ for (const inner of list) try {
919
+ return Schema.resolve(data, inner, options, strict);
920
+ } catch (error) {
921
+ messages.push(error);
922
+ }
923
+ throw new ValidationError(`expected ${toString2()} but got ${JSON.stringify(data)}`, options);
924
+ });
925
+ Schema.extend("intersect", (data, { list, toString: toString2 }, options, strict) => {
926
+ if (!list.length) return [data];
927
+ let result;
928
+ for (const inner of list) {
929
+ const value = Schema.resolve(data, inner, options, true)[0];
930
+ if ((0, import_cosmokit.isNullable)(value)) continue;
931
+ if ((0, import_cosmokit.isNullable)(result)) result = value;
932
+ else if (typeof result !== typeof value) throw new ValidationError(`expected ${toString2()} but got ${JSON.stringify(data)}`, options);
933
+ else if (typeof value === "object") merge(result ??= {}, value);
934
+ else if (result !== value) throw new ValidationError(`expected ${toString2()} but got ${JSON.stringify(data)}`, options);
935
+ }
936
+ if (!strict && (0, import_cosmokit.isPlainObject)(data)) merge(result, data);
937
+ return [result];
938
+ });
939
+ Schema.extend("transform", (data, { inner, callback, preserve }, options) => {
940
+ const [result, adapted = data] = Schema.resolve(data, inner, options, true);
941
+ if (preserve) return [callback(result)];
942
+ else return [callback(result), callback(adapted)];
943
+ });
944
+ var formatters = {};
945
+ function defineMethod(name, keys, format) {
946
+ formatters[name] = format;
947
+ Object.assign(Schema, { [name](...args) {
948
+ const schema = new Schema({ type: name });
949
+ keys.forEach((key, index) => {
950
+ switch (key) {
951
+ case "sKey":
952
+ schema.sKey = args[index] ?? Schema.string();
953
+ break;
954
+ case "inner":
955
+ schema.inner = Schema.from(args[index]);
956
+ break;
957
+ case "list":
958
+ schema.list = args[index].map(Schema.from);
959
+ break;
960
+ case "dict":
961
+ schema.dict = (0, import_cosmokit.valueMap)(args[index], Schema.from);
962
+ break;
963
+ case "bits":
964
+ schema.bits = {};
965
+ for (const key2 in args[index]) {
966
+ if (typeof args[index][key2] !== "number") continue;
967
+ schema.bits[key2] = args[index][key2];
968
+ }
969
+ break;
970
+ case "callback": {
971
+ const callback = schema.callback = args[index];
972
+ callback["toJSON"] ||= () => callback.toString();
973
+ break;
974
+ }
975
+ case "constructor": {
976
+ const constructor = schema.constructor = args[index];
977
+ if (typeof constructor === "function") constructor["toJSON"] ||= () => constructor["name"];
978
+ break;
979
+ }
980
+ default: schema[key] = args[index];
981
+ }
982
+ });
983
+ if (name === "object" || name === "dict") schema.meta.default = {};
984
+ else if (name === "array" || name === "tuple") schema.meta.default = [];
985
+ else if (name === "bitset") schema.meta.default = 0;
986
+ return schema;
987
+ } });
988
+ }
989
+ __name(defineMethod, "defineMethod");
990
+ defineMethod("is", ["constructor"], ({ constructor }) => {
991
+ if (typeof constructor === "function") return constructor.name;
992
+ else return constructor;
993
+ });
994
+ defineMethod("any", [], () => "any");
995
+ defineMethod("never", [], () => "never");
996
+ defineMethod("const", ["value"], ({ value }) => typeof value === "string" ? JSON.stringify(value) : value);
997
+ defineMethod("string", [], () => "string");
998
+ defineMethod("number", [], () => "number");
999
+ defineMethod("boolean", [], () => "boolean");
1000
+ defineMethod("bitset", ["bits"], () => "bitset");
1001
+ defineMethod("function", [], () => "function");
1002
+ defineMethod("array", ["inner"], ({ inner }) => `${inner.toString(true)}[]`);
1003
+ defineMethod("dict", ["inner", "sKey"], ({ inner, sKey }) => `{ [key: ${sKey.toString()}]: ${inner.toString()} }`);
1004
+ defineMethod("tuple", ["list"], ({ list }) => `[${list.map((inner) => inner.toString()).join(", ")}]`);
1005
+ defineMethod("object", ["dict"], ({ dict }) => {
1006
+ if (Object.keys(dict).length === 0) return "{}";
1007
+ return `{ ${Object.entries(dict).map(([key, inner]) => {
1008
+ return `${key}${inner.meta.required ? "" : "?"}: ${inner.toString()}`;
1009
+ }).join(", ")} }`;
1010
+ });
1011
+ defineMethod("union", ["list"], ({ list }, inline) => {
1012
+ const result = list.map(({ toString: format }) => format()).join(" | ");
1013
+ return inline ? `(${result})` : result;
1014
+ });
1015
+ defineMethod("intersect", ["list"], ({ list }) => {
1016
+ return `${list.map((inner) => inner.toString(true)).join(" & ")}`;
1017
+ });
1018
+ defineMethod("transform", [
1019
+ "inner",
1020
+ "callback",
1021
+ "preserve"
1022
+ ], ({ inner }, isInner) => inner.toString(isInner));
1023
+ module.exports = Schema;
1024
+ })))(), 1);
1025
+ /**
1026
+ * Minimal HTML → Markdown converter used to scrape DeepSeek's rendered
1027
+ * `.ds-markdown` replies. Covers the shapes DeepSeek renders (headings,
1028
+ * paragraphs, code blocks with syntax classes, inline code, bold/italic,
1029
+ * links, images, lists, tables, blockquotes, math placeholders). Anything
1030
+ * unrecognized falls back to its text content — fidelity loss degrades
1031
+ * gracefully instead of dropping text.
1032
+ */
1033
+ /** Escape text that could be parsed as markdown markup. */
1034
+ function escapeText(text) {
1035
+ return text.replace(/([\\`*_[\]<>])/g, "\\$1").replace(/\n{3,}/g, "\n\n");
1036
+ }
1037
+ const TEXT_NODE = 3;
1038
+ /** Convert one DOM subtree (element or text) to markdown. */
1039
+ function htmlToMarkdown(root) {
1040
+ if (root === null || root === void 0) return "";
1041
+ if (root.nodeType === TEXT_NODE) return escapeText(root.textContent ?? "");
1042
+ const tag = (root.tagName ?? "").toLowerCase();
1043
+ const text = (children) => (children ?? []).map(htmlToMarkdown).join("");
1044
+ switch (tag) {
1045
+ case "br": return "\n";
1046
+ case "hr": return "\n---\n";
1047
+ case "h1":
1048
+ case "h2":
1049
+ case "h3":
1050
+ case "h4":
1051
+ case "h5":
1052
+ case "h6": {
1053
+ const level = Number(tag[1]);
1054
+ return `\n${"#".repeat(level)} ${text(root.children).trim()}\n`;
1055
+ }
1056
+ case "p": return `\n${text(root.children).trim()}\n`;
1057
+ case "strong":
1058
+ case "b": return `**${text(root.children).trim()}**`;
1059
+ case "em":
1060
+ case "i": return `*${text(root.children).trim()}*`;
1061
+ case "code":
1062
+ if (root.parent?.tagName?.toLowerCase() === "pre") return text(root.children);
1063
+ return `\`${(root.textContent ?? "").replace(/`/g, "\\`")}\``;
1064
+ case "pre": {
1065
+ const code = root.children?.find((child) => (child.tagName ?? "").toLowerCase() === "code") ?? root;
1066
+ const raw = code.textContent ?? "";
1067
+ const className = code.className ?? "";
1068
+ const language = /language-([a-zA-Z0-9_+-]+)/.exec(className)?.[1] ?? "";
1069
+ const fence = "```";
1070
+ return `\n${fence}${language}\n${raw.replace(/\n$/, "")}\n${fence}\n`;
1071
+ }
1072
+ case "a": {
1073
+ const href = root.attributes?.href;
1074
+ const label = text(root.children).trim();
1075
+ if (href === void 0 || href === "" || href.startsWith("javascript:")) return label;
1076
+ return `[${label || href}](${href})`;
1077
+ }
1078
+ case "img": {
1079
+ const src = root.attributes?.src;
1080
+ const alt = root.attributes?.alt ?? "";
1081
+ return src === void 0 ? "" : `![${alt}](${src})`;
1082
+ }
1083
+ case "ul": return `\n${(root.children ?? []).map((child) => {
1084
+ if ((child.tagName ?? "").toLowerCase() === "li") return `- ${text(child.children).trim()}`;
1085
+ return htmlToMarkdown(child);
1086
+ }).join("\n")}\n`;
1087
+ case "ol": {
1088
+ let index = 1;
1089
+ return `\n${(root.children ?? []).map((child) => {
1090
+ if ((child.tagName ?? "").toLowerCase() === "li") return `${index++}. ${text(child.children).trim()}`;
1091
+ return htmlToMarkdown(child);
1092
+ }).join("\n")}\n`;
1093
+ }
1094
+ case "li": return text(root.children).trim();
1095
+ case "blockquote": return `\n> ${text(root.children).trim().replace(/\n/g, "\n> ")}\n`;
1096
+ case "table": {
1097
+ const rows = (root.children ?? []).filter((child) => (child.tagName ?? "").toLowerCase() === "tr");
1098
+ if (rows.length === 0) return text(root.children).trim();
1099
+ const cellsOf = (row) => (row.children ?? []).filter((child) => ["th", "td"].includes((child.tagName ?? "").toLowerCase())).map((cell) => text(cell.children).trim().replace(/\|/g, "\\|"));
1100
+ const header = cellsOf(rows[0]);
1101
+ const body = rows.slice(1).map(cellsOf);
1102
+ const width = Math.max(header.length, ...body.map((row) => row.length));
1103
+ const pad = (cells) => {
1104
+ const filled = [...cells];
1105
+ while (filled.length < width) filled.push("");
1106
+ return `| ${filled.join(" | ")} |`;
1107
+ };
1108
+ return `\n${[
1109
+ pad(header),
1110
+ `| ${Array.from({ length: width }, () => "---").join(" | ")} |`,
1111
+ ...body.map(pad)
1112
+ ].join("\n")}\n`;
1113
+ }
1114
+ case "tr":
1115
+ case "td":
1116
+ case "th":
1117
+ case "thead":
1118
+ case "tbody":
1119
+ case "tfoot": return text(root.children);
1120
+ case "details": return `\n<details>\n${text(root.children)}\n</details>\n`;
1121
+ case "summary": return `**${text(root.children).trim()}**`;
1122
+ case "input": return root.attributes?.checked !== void 0 ? "[x] " : "[ ] ";
1123
+ case "math": return `$${root.textContent ?? ""}$`;
1124
+ case "svg":
1125
+ case "button":
1126
+ case "script":
1127
+ case "style": return "";
1128
+ case "div":
1129
+ case "span":
1130
+ case "section":
1131
+ case "article":
1132
+ case "main": return text(root.children);
1133
+ default: return text(root.children);
1134
+ }
1135
+ }
1136
+ /** Convert serialized DOM (from the in-page scraper) to markdown. */
1137
+ function serializeToMarkdown(root) {
1138
+ return htmlToMarkdown(root).replace(/\n{3,}/g, "\n\n").trim();
1139
+ }
1140
+ //#endregion
1141
+ //#region src/engine/engine.ts
1142
+ /**
1143
+ * DeepSeek web engine — the "Codex ChatGPT mode" analog for DeepSeek Harness.
1144
+ *
1145
+ * Drives a real browser (system Chrome/Edge via playwright-core) against
1146
+ * chat.deepseek.com with a dedicated persistent profile, so the user logs in
1147
+ * once with their own DeepSeek account (phone / password / Apple / WeChat QR)
1148
+ * and the session persists. Chatting happens THROUGH the real web page —
1149
+ * messages are typed into the real composer — so the plugin needs no API key,
1150
+ * no billing, and stays immune to DeepSeek's private-API PoW challenge.
1151
+ *
1152
+ * Replies are read by teeing the page's own SSE stream: an injected init
1153
+ * script wraps XMLHttpRequest and captures the `/api/v0/chat/completion`
1154
+ * response as it streams (the page has already solved PoW + auth, so we get
1155
+ * the model's raw markdown for free). DOM scraping of `.ds-markdown` is kept
1156
+ * only as a fallback for when the capture cannot install. The web chat runs
1157
+ * the `deepseek-chat` model by default (switchable to deepseek-reasoner).
1158
+ *
1159
+ * All page interactions are best-effort and selector-defensive: failures
1160
+ * produce readable errors (never crashes) and the caller decides how to
1161
+ * degrade.
1162
+ */
1163
+ const DEFAULT_TIMEOUT_MS = 18e4;
1164
+ /**
1165
+ * Injected before any page script: tee the chat/completion XHR stream into
1166
+ * `window.__wcStream`. The DeepSeek web app reads its reply through an
1167
+ * XMLHttpRequest (POST /api/v0/chat/completion, responseType "text", SSE
1168
+ * body), so wrapping XHR `progress` events captures the raw `event:`/`data:`
1169
+ * stream exactly as the page receives it — no PoW, no auth, no selectors.
1170
+ * The function must stay self-contained (playwright serializes its source).
1171
+ */
1172
+ function streamCaptureInit() {
1173
+ const w = window;
1174
+ if (w.__wcCaptureInstalled === true) return;
1175
+ w.__wcCaptureInstalled = true;
1176
+ w.__wcStream = {
1177
+ text: "",
1178
+ done: false,
1179
+ started: false,
1180
+ status: 0,
1181
+ error: ""
1182
+ };
1183
+ const X = w.XMLHttpRequest;
1184
+ const origOpen = X.prototype.open;
1185
+ const origSend = X.prototype.send;
1186
+ X.prototype.open = function(method, url, ...rest) {
1187
+ this.__wcIsChat = typeof url === "string" && url.includes("/chat/completion");
1188
+ return origOpen.call(this, method, url, ...rest);
1189
+ };
1190
+ X.prototype.send = function(...args) {
1191
+ if (this.__wcIsChat === true) {
1192
+ let lastLen = 0;
1193
+ this.addEventListener("progress", () => {
1194
+ const stream = w.__wcStream;
1195
+ if (stream === void 0) return;
1196
+ const text = this.responseText ?? "";
1197
+ if (text.length > lastLen) {
1198
+ stream.text += text.slice(lastLen);
1199
+ lastLen = text.length;
1200
+ }
1201
+ stream.started = true;
1202
+ });
1203
+ this.addEventListener("loadend", () => {
1204
+ const stream = w.__wcStream;
1205
+ if (stream === void 0) return;
1206
+ stream.done = true;
1207
+ stream.status = this.status;
1208
+ if (this.status >= 400) stream.error = `HTTP ${this.status}`;
1209
+ });
1210
+ }
1211
+ return origSend.apply(this, args);
1212
+ };
1213
+ const origFetch = w.fetch.bind(w);
1214
+ w.fetch = function(input, init) {
1215
+ const url = typeof input === "string" ? input : input?.url ?? String(input);
1216
+ const isChat = typeof url === "string" && url.includes("/chat/completion");
1217
+ return origFetch(input, init).then((response) => {
1218
+ if (!isChat || response === null || response === void 0) return response;
1219
+ const body = response.body;
1220
+ if (body === null || body === void 0 || typeof body.tee !== "function") return response;
1221
+ try {
1222
+ const [pageStream, captureStream] = body.tee();
1223
+ const decoder = new w.TextDecoder();
1224
+ const reader = captureStream.getReader();
1225
+ (async () => {
1226
+ try {
1227
+ for (;;) {
1228
+ const { done, value } = await reader.read();
1229
+ if (done) break;
1230
+ const stream = w.__wcStream;
1231
+ if (stream !== void 0) {
1232
+ stream.text += decoder.decode(value, { stream: true });
1233
+ stream.started = true;
1234
+ }
1235
+ }
1236
+ const stream = w.__wcStream;
1237
+ if (stream !== void 0) {
1238
+ stream.text += decoder.decode();
1239
+ stream.done = true;
1240
+ stream.status = response.status;
1241
+ if (response.status >= 400) stream.error = `HTTP ${response.status}`;
1242
+ }
1243
+ } catch {}
1244
+ })();
1245
+ return new w.Response(pageStream, {
1246
+ status: response.status,
1247
+ statusText: response.statusText,
1248
+ headers: response.headers
1249
+ });
1250
+ } catch {
1251
+ return response;
1252
+ }
1253
+ });
1254
+ };
1255
+ }
1256
+ /**
1257
+ * DeepSeek citation numbering. The web numbers the *sources* (search results)
1258
+ * 1..M, not the `[reference:N]` markers. Each `[reference:N]` marker is paired
1259
+ * with a `references` op `{id,type}`:
1260
+ * - `TOOL_OPEN` → a specific opened page whose `result.url` matches one of
1261
+ * the search results; the citation number is that result's
1262
+ * 1-based position in the search-results list.
1263
+ * - `TOOL_SEARCH` → the search step itself, rendered by the web as a search
1264
+ * icon (no number) rather than a citation.
1265
+ * This is resolved inside `parseStreamReply`, which holds the search-results
1266
+ * list and the opened-page id→url map.
1267
+ */
1268
+ /**
1269
+ * Defensive clean-up of the DeepSeek search-agent trace tokens. The parser
1270
+ * already routes `DEEP_SEARCH` (conversation_mode) and `FINISHED` (status)
1271
+ * events away from content, so this normally runs as a no-op; it exists for
1272
+ * the DOM-scrape fallback and any residual markers.
1273
+ */
1274
+ function stripSearchTrace(text) {
1275
+ return text.replace(/DEEP_SEARCH/g, "").replace(/FINISHED+/g, "\n\n").replace(/\n{3,}/g, "\n\n").trim();
1276
+ }
1277
+ /** True when a fragment type is the R1 reasoning (THINK / THINKING). */
1278
+ function isThinkingType(type) {
1279
+ return typeof type === "string" && type.toUpperCase().includes("THINK");
1280
+ }
1281
+ /**
1282
+ * Parse the accumulated `/api/v0/chat/completion` SSE body into reply text.
1283
+ * The stream is `event:` / `data:` lines; each `data:` payload is JSON. The
1284
+ * protocol distinguishes the R1 reasoning fragment (type `THINK`) from the
1285
+ * answer fragment (type `RESPONSE`), and carries search steps as `TOOL_SEARCH`
1286
+ * / `TOOL_OPEN` fragments:
1287
+ * - {"v":{"response":{"fragments":[{"type":"THINK","content":"…"}]}}}
1288
+ * a snapshot carrying the fragment list and their types.
1289
+ * - {"p":"response/fragments","o":"APPEND","v":[{"type":"RESPONSE",…}]}
1290
+ * appends a NEW fragment (reasoning / search / answer); `-1/content`
1291
+ * deltas after this belong to that new fragment.
1292
+ * - {"p":"response/fragments/-1/content","o":"APPEND","v":"是一座"} — appends
1293
+ * a text delta to the CURRENT fragment's content.
1294
+ * - {"p":"response/fragments/-1/results","o":"SET","v":[…]} — search results
1295
+ * for a TOOL_SEARCH step (rendered as "搜索到 N 个网页").
1296
+ * - {"v":"将"} — a bare delta continuing the current fragment; a bare
1297
+ * `{"v":[{p:"content",o:"APPEND",v:"[reference:N]"},…]}` carries citation
1298
+ * markers.
1299
+ * - {"p":"response/status","o":"SET","v":"FINISHED"} — generation complete.
1300
+ */
1301
+ function parseStreamReply(raw) {
1302
+ let body = "";
1303
+ let thinking = "";
1304
+ let finished = false;
1305
+ let currentType = "RESPONSE";
1306
+ const searchResults = [];
1307
+ const openById = /* @__PURE__ */ new Map();
1308
+ let urlToIndex;
1309
+ const buildUrlIndex = () => {
1310
+ if (urlToIndex === void 0) {
1311
+ urlToIndex = /* @__PURE__ */ new Map();
1312
+ searchResults.forEach((url, i) => {
1313
+ if (url !== "" && !urlToIndex.has(url)) urlToIndex.set(url, i);
1314
+ });
1315
+ }
1316
+ return urlToIndex;
1317
+ };
1318
+ const resolveCitations = (text, refs) => {
1319
+ if (!Array.isArray(refs) || refs.length === 0) return text;
1320
+ let i = 0;
1321
+ return text.replace(/\[reference:\d+\]/g, () => {
1322
+ const ref = refs[i];
1323
+ i++;
1324
+ if (typeof ref !== "object" || ref === null) return "";
1325
+ if (ref["type"] !== "TOOL_OPEN") return "";
1326
+ const id = ref["id"];
1327
+ const url = typeof id === "number" ? openById.get(id) : void 0;
1328
+ if (url === void 0) return "";
1329
+ const idx = buildUrlIndex().get(url);
1330
+ return idx === void 0 ? "" : `[citation:${idx + 1}]`;
1331
+ });
1332
+ };
1333
+ const appendContent = (text) => {
1334
+ if (isThinkingType(currentType)) thinking += text;
1335
+ else body += text;
1336
+ };
1337
+ const appendFragments = (fragments) => {
1338
+ const opened = [];
1339
+ for (const frag of fragments) {
1340
+ if (typeof frag !== "object" || frag === null) continue;
1341
+ const f = frag;
1342
+ const type = f["type"];
1343
+ if (typeof type === "string") currentType = type;
1344
+ const content = f["content"];
1345
+ if (typeof content === "string" && content !== "") {
1346
+ if (isThinkingType(type)) thinking += content;
1347
+ else if (type === "RESPONSE" || type === "TEXT") body += content;
1348
+ }
1349
+ if (type === "TOOL_OPEN") {
1350
+ const result = f["result"];
1351
+ const title = result?.["title"];
1352
+ if (typeof title === "string" && title !== "") opened.push(title);
1353
+ const id = f["id"];
1354
+ const url = result?.["url"];
1355
+ if (typeof id === "number" && typeof url === "string" && url !== "") openById.set(id, url);
1356
+ }
1357
+ }
1358
+ if (opened.length > 0) thinking += `\n\n浏览 ${opened.length} 个页面\n${opened.map((t) => `- ${t}`).join("\n")}\n\n`;
1359
+ };
1360
+ const applyBatchOps = (ops) => {
1361
+ let contentText = "";
1362
+ let hasContent = false;
1363
+ let refs = null;
1364
+ const fragmentsList = [];
1365
+ for (const item of ops) {
1366
+ if (typeof item !== "object" || item === null) continue;
1367
+ const it = item;
1368
+ const ip = it["p"];
1369
+ const iop = it["o"];
1370
+ const iv = it["v"];
1371
+ if (ip === "content" && iop === "APPEND" && typeof iv === "string") {
1372
+ contentText += iv;
1373
+ hasContent = true;
1374
+ } else if (ip === "references" && Array.isArray(iv)) refs = iv;
1375
+ else if (ip === "fragments" && iop === "APPEND" && Array.isArray(iv)) fragmentsList.push(iv);
1376
+ }
1377
+ for (const fr of fragmentsList) appendFragments(fr);
1378
+ if (hasContent) if (isThinkingType(currentType)) thinking += contentText;
1379
+ else body += resolveCitations(contentText, refs);
1380
+ };
1381
+ for (const line of raw.split(/\r?\n/)) {
1382
+ const trimmed = line.trim();
1383
+ if (!trimmed.startsWith("data:")) continue;
1384
+ const payload = trimmed.slice(5).trim();
1385
+ if (payload === "") continue;
1386
+ let obj;
1387
+ try {
1388
+ obj = JSON.parse(payload);
1389
+ } catch {
1390
+ continue;
1391
+ }
1392
+ if (typeof obj !== "object" || obj === null) continue;
1393
+ const o = obj;
1394
+ const v = o["v"];
1395
+ if (typeof v === "object" && v !== null && !Array.isArray(v) && "response" in v) {
1396
+ const fragments = v["response"]?.["fragments"];
1397
+ if (Array.isArray(fragments)) {
1398
+ let newBody = "";
1399
+ let newThinking = "";
1400
+ for (const frag of fragments) {
1401
+ if (typeof frag !== "object" || frag === null) continue;
1402
+ const f = frag;
1403
+ const type = f["type"];
1404
+ if (typeof type === "string") currentType = type;
1405
+ const content = f["content"];
1406
+ if (typeof content !== "string" || content === "") continue;
1407
+ if (isThinkingType(type)) newThinking += content;
1408
+ else if (type === "RESPONSE" || type === "TEXT") newBody += content;
1409
+ }
1410
+ if (newBody !== "") body = newBody;
1411
+ if (newThinking !== "") thinking = newThinking;
1412
+ }
1413
+ continue;
1414
+ }
1415
+ const p = o["p"];
1416
+ const op = o["o"];
1417
+ if (p === "response/fragments" && op === "APPEND" && Array.isArray(v)) {
1418
+ appendFragments(v);
1419
+ continue;
1420
+ }
1421
+ if (typeof p === "string") {
1422
+ if (typeof v === "string" && p === "response/fragments/-1/content" && op !== "SET") appendContent(v);
1423
+ else if (op === "SET" && p === "response/fragments/-1/results" && Array.isArray(v)) {
1424
+ for (const r of v) if (typeof r === "object" && r !== null) {
1425
+ const url = r["url"];
1426
+ if (typeof url === "string" && url !== "") searchResults.push(url);
1427
+ }
1428
+ thinking += `\n\n搜索到 ${v.length} 个网页\n\n`;
1429
+ } else if (op === "SET" && p === "response/status" && v === "FINISHED") finished = true;
1430
+ else if (op === "BATCH" && Array.isArray(v)) applyBatchOps(v);
1431
+ continue;
1432
+ }
1433
+ if (Array.isArray(v)) {
1434
+ applyBatchOps(v);
1435
+ continue;
1436
+ }
1437
+ if (typeof v === "string") appendContent(v);
1438
+ }
1439
+ return {
1440
+ markdown: [thinking.trim() === "" ? "" : `<details><summary>思考过程</summary>\n\n${thinking.trim()}\n\n</details>`, body.trim()].filter((s) => s !== "").join("\n\n"),
1441
+ thinking,
1442
+ finished
1443
+ };
1444
+ }
1445
+ /** Order-preserving promise queue — the browser page handles one chat op at a time. */
1446
+ var SerialQueue = class {
1447
+ tail = Promise.resolve();
1448
+ run(task) {
1449
+ const next = this.tail.then(task, task);
1450
+ this.tail = next.catch(() => void 0);
1451
+ return next;
1452
+ }
1453
+ };
1454
+ var DeepSeekWebEngine = class {
1455
+ store;
1456
+ config;
1457
+ profileDir;
1458
+ context;
1459
+ page;
1460
+ queue = new SerialQueue();
1461
+ state = "stopped";
1462
+ engineError;
1463
+ busy = false;
1464
+ lastError;
1465
+ lastErrorCode;
1466
+ launchedOnce = false;
1467
+ /** True while a headed one-time login window is open (auto-closes on login). */
1468
+ loginMode = false;
1469
+ /** Remembered login state — survives the auto-close so the panel stays "已登录". */
1470
+ loggedInOnce = false;
1471
+ /** Commanded toggle state (best-effort read-back overrides on status). */
1472
+ deepThink = false;
1473
+ search = false;
1474
+ constructor(store, config) {
1475
+ this.store = store;
1476
+ this.config = config;
1477
+ this.profileDir = join(config.dataDir, "browser-profile");
1478
+ }
1479
+ /** Coarse state for status snapshots. */
1480
+ getState() {
1481
+ return this.state;
1482
+ }
1483
+ getEngineError() {
1484
+ return this.engineError;
1485
+ }
1486
+ getBusy() {
1487
+ return this.busy;
1488
+ }
1489
+ getLastError() {
1490
+ return this.lastError;
1491
+ }
1492
+ getLastErrorCode() {
1493
+ return this.lastErrorCode;
1494
+ }
1495
+ /** Set the last error + its structured code together (keeps them in sync). */
1496
+ setLastError(message, code) {
1497
+ this.lastError = message;
1498
+ this.lastErrorCode = code;
1499
+ }
1500
+ setState(next, error) {
1501
+ this.state = next;
1502
+ this.engineError = error;
1503
+ }
1504
+ /** Resolve a browser launch descriptor (executable + args). */
1505
+ launchOptions() {
1506
+ const args = [];
1507
+ const proxy = this.config.proxy ?? "direct";
1508
+ if (proxy === "direct") args.push("--no-proxy-server");
1509
+ else if (proxy.startsWith("http")) args.push(`--proxy-server=${proxy}`);
1510
+ if (this.config.executablePath !== void 0) return {
1511
+ executablePath: this.config.executablePath,
1512
+ args
1513
+ };
1514
+ if (this.config.channel !== void 0 && this.config.channel !== "auto") return {
1515
+ channel: this.config.channel,
1516
+ args
1517
+ };
1518
+ return {
1519
+ ...[
1520
+ { channel: "chrome" },
1521
+ { channel: "msedge" },
1522
+ { channel: "chromium" },
1523
+ { executablePath: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" },
1524
+ { executablePath: "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" },
1525
+ { executablePath: "/usr/bin/google-chrome" },
1526
+ { executablePath: "/usr/bin/google-chrome-stable" },
1527
+ { executablePath: "/usr/bin/microsoft-edge" },
1528
+ { executablePath: "/usr/bin/chromium" },
1529
+ { executablePath: "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" },
1530
+ { executablePath: "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe" }
1531
+ ][0],
1532
+ args
1533
+ };
1534
+ }
1535
+ /**
1536
+ * Ensure the browser + chat.deepseek.com page exist. Launches the persistent
1537
+ * context on first call; subsequent calls reuse the page.
1538
+ */
1539
+ /** True when the cached page/context are still connected (not closed by the user). */
1540
+ isPageAlive() {
1541
+ if (this.page === void 0 || this.context === void 0) return false;
1542
+ try {
1543
+ return !this.page.isClosed();
1544
+ } catch {
1545
+ return false;
1546
+ }
1547
+ }
1548
+ async ensureBrowser() {
1549
+ if (this.isPageAlive()) return this.page;
1550
+ if (this.state === "launching") {
1551
+ for (let attempt = 0; attempt < 100 && !this.isPageAlive(); attempt++) await new Promise((resolve) => setTimeout(resolve, 100));
1552
+ if (this.isPageAlive()) return this.page;
1553
+ throw new Error("浏览器启动超时");
1554
+ }
1555
+ if (this.page !== void 0 || this.context !== void 0) await this.disposeBrowser();
1556
+ this.setState("launching");
1557
+ try {
1558
+ mkdirSync(this.profileDir, {
1559
+ recursive: true,
1560
+ mode: 448
1561
+ });
1562
+ const options = this.launchOptions();
1563
+ const attemptOrder = this.config.executablePath !== void 0 || this.config.channel !== void 0 && this.config.channel !== "auto" ? [options] : this.launchOptionsCandidates();
1564
+ let lastError;
1565
+ for (const attempt of attemptOrder) try {
1566
+ this.context = await chromium.launchPersistentContext(this.profileDir, {
1567
+ ...attempt,
1568
+ headless: this.loginMode ? false : this.config.headless ?? true,
1569
+ viewport: null,
1570
+ args: options.args
1571
+ });
1572
+ lastError = void 0;
1573
+ break;
1574
+ } catch (error) {
1575
+ lastError = error;
1576
+ await this.disposeBrowser();
1577
+ }
1578
+ if (lastError !== void 0) {
1579
+ this.setState("error", `无法启动浏览器(请检查 Chrome/Edge 是否已安装,或在插件设置中指定可执行文件路径): ${String(lastError)}`);
1580
+ throw new Error(this.engineError);
1581
+ }
1582
+ const pages = this.context.pages();
1583
+ this.page = pages[0] ?? await this.context.newPage();
1584
+ this.page.setDefaultTimeout(15e3);
1585
+ await this.page.addInitScript(streamCaptureInit);
1586
+ await this.openDeepSeekPage();
1587
+ this.setState("ready");
1588
+ this.launchedOnce = true;
1589
+ return this.page;
1590
+ } catch (error) {
1591
+ if (this.state !== "error") this.setState("error", String(error));
1592
+ throw error;
1593
+ }
1594
+ }
1595
+ /** The candidate list used during auto-detection. */
1596
+ launchOptionsCandidates() {
1597
+ return [
1598
+ { channel: "chrome" },
1599
+ { channel: "msedge" },
1600
+ { channel: "chromium" },
1601
+ { executablePath: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" },
1602
+ { executablePath: "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge" },
1603
+ { executablePath: "/usr/bin/google-chrome" },
1604
+ { executablePath: "/usr/bin/google-chrome-stable" },
1605
+ { executablePath: "/usr/bin/microsoft-edge" },
1606
+ { executablePath: "/usr/bin/chromium" },
1607
+ { executablePath: "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe" },
1608
+ { executablePath: "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe" }
1609
+ ].filter((candidate) => {
1610
+ if (candidate.channel !== void 0) return true;
1611
+ return candidate.executablePath !== void 0 && existsSync(candidate.executablePath);
1612
+ });
1613
+ }
1614
+ /** Navigate to the DeepSeek chat root. */
1615
+ async openDeepSeekPage() {
1616
+ if (this.page === void 0) throw new Error("浏览器尚未启动");
1617
+ const baseUrl = this.config.baseUrl ?? "https://chat.deepseek.com";
1618
+ try {
1619
+ await this.page.goto(baseUrl, {
1620
+ waitUntil: "domcontentloaded",
1621
+ timeout: 45e3
1622
+ });
1623
+ await this.page.waitForTimeout(2500);
1624
+ } catch (error) {
1625
+ this.setState("error", `无法打开 ${baseUrl}:${String(error)}`);
1626
+ throw new Error(this.engineError);
1627
+ }
1628
+ }
1629
+ /** True when the page shows the chat UI (not the login page). */
1630
+ async isLoggedIn() {
1631
+ if (!this.isPageAlive()) return null;
1632
+ try {
1633
+ const url = this.page.url();
1634
+ if (url.includes("/sign_in") || url.includes("/auth")) return false;
1635
+ return await this.page.locator("textarea").count().then((count) => count > 0).catch(() => false);
1636
+ } catch {
1637
+ return null;
1638
+ }
1639
+ }
1640
+ /**
1641
+ * Open a visible browser window for the one-time login. The window is forced
1642
+ * headed (login needs a user) and auto-closes as soon as the page reaches the
1643
+ * chat UI; normal chatting then runs headless on the persisted profile.
1644
+ */
1645
+ async openLoginWindow() {
1646
+ try {
1647
+ if (this.page !== void 0 || this.context !== void 0) await this.disposeBrowser();
1648
+ this.loginMode = true;
1649
+ await this.ensureBrowser();
1650
+ await this.page?.bringToFront();
1651
+ this.watchLoginAndClose();
1652
+ return { ok: true };
1653
+ } catch (error) {
1654
+ this.loginMode = false;
1655
+ return {
1656
+ ok: false,
1657
+ error: String(error)
1658
+ };
1659
+ }
1660
+ }
1661
+ /** Poll the login window and close it once the user has logged in. */
1662
+ async watchLoginAndClose() {
1663
+ for (let attempt = 0; attempt < 600; attempt++) {
1664
+ if (!this.loginMode) return;
1665
+ if (!this.isPageAlive()) {
1666
+ this.loginMode = false;
1667
+ return;
1668
+ }
1669
+ if (await this.isLoggedIn() === true) {
1670
+ this.loginMode = false;
1671
+ this.loggedInOnce = true;
1672
+ await this.disposeBrowser().catch(() => void 0);
1673
+ return;
1674
+ }
1675
+ await new Promise((resolve) => setTimeout(resolve, 1e3));
1676
+ }
1677
+ }
1678
+ /** Current page URL (for status/debug). */
1679
+ pageUrl() {
1680
+ if (!this.isPageAlive()) return void 0;
1681
+ try {
1682
+ return this.page?.url();
1683
+ } catch {
1684
+ return;
1685
+ }
1686
+ }
1687
+ /**
1688
+ * Best-effort read of the deep-think (R1) and search toggle state from the
1689
+ * page. The toggles are `div.ds-toggle-button` elements (NOT `<button>`)
1690
+ * carrying `aria-pressed` plus a `ds-toggle-button--selected` class when on;
1691
+ * the search toggle is labeled 智能搜索. Falls back to the last commanded
1692
+ * state when the page gives no clear signal.
1693
+ */
1694
+ async readToggles() {
1695
+ if (!this.isPageAlive()) return {
1696
+ deepThink: this.deepThink,
1697
+ search: this.search
1698
+ };
1699
+ try {
1700
+ const pageState = await this.page.evaluate(() => {
1701
+ const read = (candidates) => {
1702
+ for (const el of Array.from(document.querySelectorAll("[aria-pressed]"))) {
1703
+ const label = `${el.textContent ?? ""} ${el.getAttribute("aria-label") ?? ""}`;
1704
+ if (!candidates.some((candidate) => label.includes(candidate))) continue;
1705
+ const pressed = el.getAttribute("aria-pressed");
1706
+ if (pressed === "true") return true;
1707
+ if (pressed === "false") return false;
1708
+ const cls = typeof el.className === "string" ? el.className : "";
1709
+ if (/ds-toggle-button--selected|--selected|active|checked/i.test(cls)) return true;
1710
+ }
1711
+ };
1712
+ return {
1713
+ deepThink: read([
1714
+ "深度思考",
1715
+ "DeepThink",
1716
+ "Deep Think",
1717
+ "R1"
1718
+ ]),
1719
+ search: read([
1720
+ "智能搜索",
1721
+ "联网搜索",
1722
+ "搜索",
1723
+ "Search"
1724
+ ])
1725
+ };
1726
+ });
1727
+ return {
1728
+ deepThink: pageState.deepThink ?? this.deepThink,
1729
+ search: pageState.search ?? this.search
1730
+ };
1731
+ } catch {
1732
+ return {
1733
+ deepThink: this.deepThink,
1734
+ search: this.search
1735
+ };
1736
+ }
1737
+ }
1738
+ /** Serialized page evaluation guarded against a dead page. */
1739
+ async evalPage(fn) {
1740
+ if (this.page === void 0) throw new Error("浏览器尚未启动");
1741
+ return this.page.evaluate(fn);
1742
+ }
1743
+ /**
1744
+ * In-page scraper: returns the ordered rendered messages currently in the
1745
+ * DOM. Uses the virtual-list item keys as message boundaries and the
1746
+ * assistant-main-content class to split roles. Fallback only — the primary
1747
+ * reply source is the teed SSE stream.
1748
+ */
1749
+ async scrapeConversation() {
1750
+ if (this.page === void 0) return [];
1751
+ return await this.page.evaluate(() => {
1752
+ const extract = (element) => {
1753
+ const clone = element.cloneNode(true);
1754
+ for (const junk of clone.querySelectorAll(".ds-markdown-code-copy-button, button, svg, [class*=\"copy\"]")) junk.remove();
1755
+ return {
1756
+ markdown: clone.innerHTML,
1757
+ text: clone.innerText
1758
+ };
1759
+ };
1760
+ const out = [];
1761
+ const items = document.querySelectorAll("[data-virtual-list-item-key]");
1762
+ for (const item of Array.from(items)) {
1763
+ const assistant = item.querySelector(".ds-assistant-message-main-content");
1764
+ if (assistant !== null) {
1765
+ const parts = [];
1766
+ const think = item.querySelector(".ds-think-content");
1767
+ if (think !== null) parts.push({
1768
+ kind: "think",
1769
+ ...extract(think)
1770
+ });
1771
+ const body = assistant.classList.contains("ds-markdown") ? assistant : assistant.querySelector(".ds-markdown");
1772
+ if (body !== null) parts.push({
1773
+ kind: "body",
1774
+ ...extract(body)
1775
+ });
1776
+ if (parts.length > 0) out.push({
1777
+ role: "assistant",
1778
+ parts
1779
+ });
1780
+ } else {
1781
+ const clone = item.cloneNode(true);
1782
+ for (const junk of clone.querySelectorAll("button, svg, [class*=\"copy\"]")) junk.remove();
1783
+ const text = (clone.innerText ?? "").trim();
1784
+ if (text !== "") out.push({
1785
+ role: "user",
1786
+ parts: [{
1787
+ kind: "body",
1788
+ markdown: "",
1789
+ text
1790
+ }]
1791
+ });
1792
+ }
1793
+ }
1794
+ return out;
1795
+ });
1796
+ }
1797
+ /** Convert scraped DOM messages into transcript messages (markdown content). */
1798
+ scrapedToMessages(scraped) {
1799
+ return scraped.map((message) => {
1800
+ if (message.role === "user") {
1801
+ const text = message.parts.map((part) => part.text).join("\n\n").trim();
1802
+ return {
1803
+ id: randomUUID(),
1804
+ role: "user",
1805
+ content: text === "" ? "(无内容)" : text,
1806
+ ts: Date.now()
1807
+ };
1808
+ }
1809
+ const think = message.parts.filter((part) => part.kind === "think").map((part) => part.text).join("\n\n").trim();
1810
+ const bodyHtml = message.parts.find((part) => part.kind === "body")?.markdown ?? "";
1811
+ const bodyMd = bodyHtml === "" ? "" : serializeToMarkdown(parseMarkup(bodyHtml));
1812
+ const thinkMd = think === "" ? "" : `<details><summary>思考过程</summary>\n\n${think}\n\n</details>`;
1813
+ return {
1814
+ id: randomUUID(),
1815
+ role: "assistant",
1816
+ content: [thinkMd, bodyMd].filter(Boolean).join("\n\n"),
1817
+ ts: Date.now()
1818
+ };
1819
+ });
1820
+ }
1821
+ /**
1822
+ * Scrape the DeepSeek sidebar conversation titles (best effort). The web
1823
+ * conversation list has no stable contract, so several candidate selectors
1824
+ * are probed and short non-menu texts are returned.
1825
+ */
1826
+ async listWebConversations() {
1827
+ if (this.page === void 0) return [];
1828
+ return (await this.page.evaluate(() => {
1829
+ const found = /* @__PURE__ */ new Set();
1830
+ for (const selector of [
1831
+ "[class*=\"conversation\"]",
1832
+ "[class*=\"chat-item\"]",
1833
+ "[class*=\"session-item\"]",
1834
+ "nav a",
1835
+ "aside a",
1836
+ "[class*=\"sidebar\"] a"
1837
+ ]) for (const el of Array.from(document.querySelectorAll(selector))) {
1838
+ const text = (el.textContent ?? "").trim().replace(/\s+/g, " ");
1839
+ if (text.length >= 1 && text.length <= 80 && !/^(新对话|New chat|删除|重命名|清空|设置|登出)/i.test(text)) found.add(text);
1840
+ }
1841
+ return Array.from(found).slice(0, 50);
1842
+ }).catch(() => [])).map((title) => ({ title }));
1843
+ }
1844
+ /** Click a sidebar conversation whose text contains the given title. */
1845
+ async clickConversationByTitle(title) {
1846
+ if (this.page === void 0) return false;
1847
+ for (const selector of [
1848
+ "[class*=\"conversation\"]",
1849
+ "[class*=\"chat-item\"]",
1850
+ "[class*=\"session-item\"]",
1851
+ "nav a",
1852
+ "aside a"
1853
+ ]) {
1854
+ const locator = this.page.locator(selector).filter({ hasText: title }).first();
1855
+ if (await locator.count().catch(() => 0) > 0) {
1856
+ await locator.click({ timeout: 5e3 }).catch(() => void 0);
1857
+ return true;
1858
+ }
1859
+ }
1860
+ return false;
1861
+ }
1862
+ /**
1863
+ * Recover a web conversation into the local transcript store: open it in the
1864
+ * sidebar, scrape its history, and import it (idempotent by title).
1865
+ */
1866
+ async recoverWebConversation(title) {
1867
+ if (this.page === void 0) try {
1868
+ await this.ensureBrowser();
1869
+ } catch (error) {
1870
+ return {
1871
+ ok: false,
1872
+ error: String(error)
1873
+ };
1874
+ }
1875
+ if (await this.isLoggedIn() !== true) return {
1876
+ ok: false,
1877
+ error: "尚未登录 DeepSeek 网页端"
1878
+ };
1879
+ try {
1880
+ if (!await this.clickConversationByTitle(title)) return {
1881
+ ok: false,
1882
+ error: `未在网页端找到会话「${title}」`
1883
+ };
1884
+ await this.page.waitForTimeout(1500);
1885
+ const scraped = await this.scrapeConversation();
1886
+ if (scraped.length === 0) return {
1887
+ ok: false,
1888
+ error: "读取网页会话历史失败(页面可能已改版)"
1889
+ };
1890
+ const messages = this.scrapedToMessages(scraped);
1891
+ const model = this.deepThink ? "deepseek-reasoner" : "deepseek-chat";
1892
+ const result = this.store.importTranscript({
1893
+ title,
1894
+ model,
1895
+ messages
1896
+ });
1897
+ return {
1898
+ ok: true,
1899
+ chatId: result.chat.id,
1900
+ title: result.chat.title,
1901
+ created: result.created
1902
+ };
1903
+ } catch (error) {
1904
+ return {
1905
+ ok: false,
1906
+ error: String(error)
1907
+ };
1908
+ }
1909
+ }
1910
+ /** Detect whether the page is currently generating (stop affordance visible). */
1911
+ async isGenerating() {
1912
+ if (this.page === void 0) return false;
1913
+ try {
1914
+ for (const selector of [
1915
+ "button[aria-label*=\"停止\"]",
1916
+ "[aria-label*=\"stop generating\" i]",
1917
+ "button:has-text(\"停止生成\")",
1918
+ "button:has-text(\"Stop generating\")"
1919
+ ]) if (await this.page.locator(selector).count().catch(() => 0) > 0) return true;
1920
+ return false;
1921
+ } catch {
1922
+ return false;
1923
+ }
1924
+ }
1925
+ /** Click the stop-generation affordance, best effort. */
1926
+ async stop() {
1927
+ await this.queue.run(async () => {
1928
+ if (this.page === void 0) return;
1929
+ for (const selector of [
1930
+ "button[aria-label*=\"停止\"]",
1931
+ "[aria-label*=\"stop generating\" i]",
1932
+ "button:has-text(\"停止生成\")",
1933
+ "button:has-text(\"Stop generating\")"
1934
+ ]) {
1935
+ const locator = this.page.locator(selector).first();
1936
+ if (await locator.count().catch(() => 0) > 0) {
1937
+ await locator.click({ timeout: 5e3 }).catch(() => void 0);
1938
+ return;
1939
+ }
1940
+ }
1941
+ });
1942
+ }
1943
+ /** Find the composer textarea (defensive selector list). */
1944
+ async composerLocator() {
1945
+ for (const selector of [
1946
+ "#chat-input",
1947
+ "textarea[placeholder*=\"给 DeepSeek\"]",
1948
+ "textarea[placeholder*=\"发送消息\"]",
1949
+ "textarea[placeholder*=\"Send a message\"]",
1950
+ "textarea"
1951
+ ]) {
1952
+ const locator = this.page.locator(selector).first();
1953
+ if (await locator.count().catch(() => 0) > 0) return locator;
1954
+ }
1955
+ return this.page.locator("textarea").first();
1956
+ }
1957
+ /**
1958
+ * Upload local image files into the composer through the page's (usually
1959
+ * hidden) file input. `setInputFiles` fires the input's change event, which
1960
+ * is how DeepSeek picks up attachments without clicking its native dialog.
1961
+ */
1962
+ async attachImages(paths) {
1963
+ if (this.page === void 0) return {
1964
+ ok: false,
1965
+ error: "浏览器未启动"
1966
+ };
1967
+ if (paths.length === 0) return { ok: true };
1968
+ for (const selector of ["input[type=\"file\"][accept*=\"image\" i]", "input[type=\"file\"]"]) {
1969
+ const input = this.page.locator(selector).first();
1970
+ if (await input.count().catch(() => 0) === 0) continue;
1971
+ try {
1972
+ await input.setInputFiles(paths);
1973
+ await this.page.waitForTimeout(1e3);
1974
+ return { ok: true };
1975
+ } catch (error) {
1976
+ return {
1977
+ ok: false,
1978
+ error: `图片上传失败:${String(error)}`
1979
+ };
1980
+ }
1981
+ }
1982
+ return {
1983
+ ok: false,
1984
+ error: "未找到图片上传入口(页面可能已改版或当前会话不支持图片)"
1985
+ };
1986
+ }
1987
+ /**
1988
+ * Send a message through the real web page.
1989
+ * @param text - message text.
1990
+ * @param wait - when true (agent tools), resolve with the final reply after
1991
+ * streaming completes; when false (GUI), resolve right after the message
1992
+ * is submitted — the reply streams in the background into the transcript
1993
+ * and the panel polls it live.
1994
+ */
1995
+ send(text, wait = false, images) {
1996
+ return this.queue.run(() => this.sendImpl(text, wait, images));
1997
+ }
1998
+ async sendImpl(text, wait, images) {
1999
+ this.setLastError(void 0);
2000
+ if (this.page === void 0) try {
2001
+ await this.ensureBrowser();
2002
+ } catch (error) {
2003
+ const message = String(error);
2004
+ this.setLastError(message, "NETWORK");
2005
+ return {
2006
+ ok: false,
2007
+ error: message,
2008
+ code: "NETWORK"
2009
+ };
2010
+ }
2011
+ if (await this.isLoggedIn() !== true) {
2012
+ const message = "尚未登录 DeepSeek 网页端。请在插件面板点击「打开登录窗口」,在弹出的浏览器中完成登录后重试。";
2013
+ this.setLastError(message, "NEED_LOGIN");
2014
+ return {
2015
+ ok: false,
2016
+ error: message,
2017
+ code: "NEED_LOGIN"
2018
+ };
2019
+ }
2020
+ try {
2021
+ const chat = this.store.ensureActiveChat(this.deepThink ? "deepseek-reasoner" : "deepseek-chat");
2022
+ const userMessage = {
2023
+ id: randomUUID(),
2024
+ role: "user",
2025
+ content: text,
2026
+ ts: Date.now(),
2027
+ ...images !== void 0 && images.length > 0 ? { attachments: images } : {}
2028
+ };
2029
+ this.store.appendMessage(chat.id, userMessage);
2030
+ if (chat.title === "新的对话") this.store.renameChat(chat.id, text.replace(/\s+/g, " ").slice(0, 40));
2031
+ const page = this.page;
2032
+ if (page === void 0) return {
2033
+ ok: false,
2034
+ error: "浏览器未启动"
2035
+ };
2036
+ const composer = await this.composerLocator();
2037
+ await composer.waitFor({
2038
+ state: "visible",
2039
+ timeout: 15e3
2040
+ }).catch(() => void 0);
2041
+ await composer.click({ timeout: 5e3 }).catch(() => void 0);
2042
+ await composer.fill(text, { timeout: 1e4 }).catch(async () => {
2043
+ await composer.type(text, { delay: 5 });
2044
+ });
2045
+ if (images !== void 0 && images.length > 0) {
2046
+ const attach = await this.attachImages(images);
2047
+ if (!attach.ok) {
2048
+ const message = attach.error ?? "图片上传失败";
2049
+ this.setLastError(message, "NETWORK");
2050
+ return {
2051
+ ok: false,
2052
+ error: message,
2053
+ code: "NETWORK"
2054
+ };
2055
+ }
2056
+ }
2057
+ await page.evaluate(() => {
2058
+ const w = window;
2059
+ w.__wcStream = {
2060
+ text: "",
2061
+ done: false,
2062
+ started: false,
2063
+ status: 0,
2064
+ error: ""
2065
+ };
2066
+ }).catch(() => void 0);
2067
+ await page.keyboard.press("Enter");
2068
+ const assistantId = randomUUID();
2069
+ if (!wait) {
2070
+ this.streamReply(chat.id, assistantId);
2071
+ return {
2072
+ ok: true,
2073
+ chatId: chat.id
2074
+ };
2075
+ }
2076
+ return await this.streamReply(chat.id, assistantId);
2077
+ } catch (error) {
2078
+ const message = `发送失败:${String(error)}`;
2079
+ this.setLastError(message);
2080
+ return {
2081
+ ok: false,
2082
+ error: message
2083
+ };
2084
+ }
2085
+ }
2086
+ /**
2087
+ * Background reply loop. Primary source is the teed SSE stream (raw model
2088
+ * markdown, no selectors); if the capture never installs, falls back to
2089
+ * scraping the rendered DOM. Writes the growing reply into the transcript
2090
+ * until the stream reports done/FINISHED or the timeout hits.
2091
+ */
2092
+ async streamReply(chatId, assistantId) {
2093
+ if (this.page === void 0) return {
2094
+ ok: false,
2095
+ error: "浏览器未启动"
2096
+ };
2097
+ this.busy = true;
2098
+ const started = Date.now();
2099
+ const timeout = this.config.replyTimeoutMs ?? DEFAULT_TIMEOUT_MS;
2100
+ let replyMarkdown = "";
2101
+ let replyError;
2102
+ let replyCode;
2103
+ let domStable = 0;
2104
+ let lastDom = "";
2105
+ const readCapture = async () => {
2106
+ if (this.page === void 0) return null;
2107
+ return this.page.evaluate(() => {
2108
+ return window.__wcStream ?? null;
2109
+ }).catch(() => null);
2110
+ };
2111
+ const domSnapshot = async () => {
2112
+ const assistant = [...await this.scrapeConversation()].reverse().find((message) => message.role === "assistant");
2113
+ if (assistant === void 0) return { markdown: "" };
2114
+ const think = stripSearchTrace(assistant.parts.filter((part) => part.kind === "think").map((part) => part.text).join("\n\n")).trim();
2115
+ const bodyHtml = assistant.parts.find((part) => part.kind === "body")?.markdown ?? "";
2116
+ const bodyMd = bodyHtml === "" ? "" : stripSearchTrace(serializeToMarkdown(parseMarkup(bodyHtml)));
2117
+ return { markdown: [think === "" ? "" : `<details><summary>思考过程</summary>\n\n${think}\n\n</details>`, bodyMd].filter(Boolean).join("\n\n") };
2118
+ };
2119
+ try {
2120
+ await this.page.waitForTimeout(700);
2121
+ let captureSeen = false;
2122
+ let captureCompleted = false;
2123
+ while (Date.now() - started < timeout) {
2124
+ const capture = await readCapture();
2125
+ if (capture !== null && capture.started) {
2126
+ captureSeen = true;
2127
+ const parsed = parseStreamReply(capture.text);
2128
+ if (parsed.markdown !== "") {
2129
+ replyMarkdown = parsed.markdown;
2130
+ this.store.upsertMessage(chatId, {
2131
+ id: assistantId,
2132
+ role: "assistant",
2133
+ content: replyMarkdown,
2134
+ ts: Date.now(),
2135
+ streaming: !(capture.done || parsed.finished)
2136
+ });
2137
+ }
2138
+ if (capture.done || parsed.finished) {
2139
+ captureCompleted = true;
2140
+ break;
2141
+ }
2142
+ if (capture.error !== "") {
2143
+ replyError = capture.error;
2144
+ replyCode = "NETWORK";
2145
+ break;
2146
+ }
2147
+ } else {
2148
+ const dom = await domSnapshot();
2149
+ if (dom.markdown !== "") {
2150
+ if (dom.markdown !== lastDom) {
2151
+ lastDom = dom.markdown;
2152
+ domStable = 0;
2153
+ replyMarkdown = dom.markdown;
2154
+ } else domStable += 1;
2155
+ this.store.upsertMessage(chatId, {
2156
+ id: assistantId,
2157
+ role: "assistant",
2158
+ content: replyMarkdown,
2159
+ ts: Date.now(),
2160
+ streaming: true
2161
+ });
2162
+ }
2163
+ if (replyMarkdown !== "" && domStable >= 3) break;
2164
+ }
2165
+ await this.page.waitForTimeout(350);
2166
+ }
2167
+ if (replyMarkdown === "" && replyCode === void 0) if (captureSeen && captureCompleted) {
2168
+ replyError = "页面协议疑似改版:已捕获到回复流但无法解析出内容,请升级 dsh-webchat 插件";
2169
+ replyCode = "PAGE_CHANGED";
2170
+ } else {
2171
+ replyError = "等待回复超时(未捕获到网页回复流;可能未登录或页面结构已变化)";
2172
+ replyCode = "TIMEOUT";
2173
+ }
2174
+ else if (replyCode === void 0 && Date.now() - started >= timeout) {
2175
+ replyError = "生成超时,已返回部分内容";
2176
+ replyCode = "TIMEOUT";
2177
+ }
2178
+ this.store.upsertMessage(chatId, {
2179
+ id: assistantId,
2180
+ role: "assistant",
2181
+ content: replyMarkdown,
2182
+ ts: Date.now(),
2183
+ streaming: false,
2184
+ error: replyError
2185
+ });
2186
+ this.store.setStreaming(chatId, false);
2187
+ if (replyError !== void 0) this.setLastError(replyError, replyCode);
2188
+ return {
2189
+ ok: replyError === void 0,
2190
+ chatId,
2191
+ reply: replyMarkdown,
2192
+ error: replyError,
2193
+ code: replyCode
2194
+ };
2195
+ } catch (error) {
2196
+ const message = `生成过程中断:${String(error)}`;
2197
+ this.setLastError(message);
2198
+ this.store.upsertMessage(chatId, {
2199
+ id: assistantId,
2200
+ role: "assistant",
2201
+ content: replyMarkdown,
2202
+ ts: Date.now(),
2203
+ streaming: false,
2204
+ error: message
2205
+ });
2206
+ this.store.setStreaming(chatId, false);
2207
+ return {
2208
+ ok: false,
2209
+ chatId,
2210
+ reply: replyMarkdown,
2211
+ error: message
2212
+ };
2213
+ } finally {
2214
+ this.busy = false;
2215
+ }
2216
+ }
2217
+ /** Start a new chat on the web page (best effort) + a fresh local transcript. */
2218
+ async newChat() {
2219
+ if (this.busy) return {
2220
+ ok: false,
2221
+ error: "正在生成回复,请先停止或等待完成"
2222
+ };
2223
+ return this.queue.run(async () => {
2224
+ try {
2225
+ await this.ensureBrowser();
2226
+ const chat = this.store.createChat(this.deepThink ? "deepseek-reasoner" : "deepseek-chat");
2227
+ if (this.page !== void 0) {
2228
+ const clickSelectors = [
2229
+ "button:has-text(\"新对话\")",
2230
+ "button:has-text(\"New chat\")",
2231
+ "[class*=\"newChat\"]"
2232
+ ];
2233
+ let clicked = false;
2234
+ for (const selector of clickSelectors) {
2235
+ const locator = this.page.locator(selector).first();
2236
+ if (await locator.count().catch(() => 0) > 0) {
2237
+ await locator.click({ timeout: 5e3 }).catch(() => void 0);
2238
+ clicked = true;
2239
+ break;
2240
+ }
2241
+ }
2242
+ if (!clicked) await this.openDeepSeekPage();
2243
+ await this.page.waitForTimeout(1500);
2244
+ }
2245
+ return {
2246
+ ok: true,
2247
+ chatId: chat.id
2248
+ };
2249
+ } catch (error) {
2250
+ return {
2251
+ ok: false,
2252
+ error: String(error)
2253
+ };
2254
+ }
2255
+ });
2256
+ }
2257
+ /**
2258
+ * Click a toggle on the page by label candidates (best effort — the DeepSeek
2259
+ * web UI has no stable contract, so a miss is not an error). The toggles are
2260
+ * `div.ds-toggle-button` elements (not `<button>`), so those selectors come
2261
+ * first; `<button>` variants remain as fallbacks for older page versions.
2262
+ * @returns true when a candidate was clicked.
2263
+ */
2264
+ async clickToggle(labels) {
2265
+ if (!this.isPageAlive()) return false;
2266
+ const selectors = [];
2267
+ for (const label of labels) selectors.push(`div.ds-toggle-button:has-text("${label}")`, `[aria-pressed]:has-text("${label}")`, `button:has-text("${label}")`, `[aria-label*="${label}"]`);
2268
+ for (const selector of selectors) {
2269
+ const locator = this.page.locator(selector).first();
2270
+ if (await locator.count().catch(() => 0) > 0) {
2271
+ await locator.click({ timeout: 5e3 }).catch(() => void 0);
2272
+ return true;
2273
+ }
2274
+ }
2275
+ return false;
2276
+ }
2277
+ /** Toggle deep-think (R1) mode on the web page. */
2278
+ async setDeepThink(enabled) {
2279
+ if (this.busy) return {
2280
+ ok: false,
2281
+ error: "正在生成回复,请先等待完成"
2282
+ };
2283
+ return this.queue.run(async () => {
2284
+ try {
2285
+ await this.ensureBrowser();
2286
+ if ((await this.readToggles()).deepThink !== enabled) await this.clickToggle([
2287
+ "深度思考",
2288
+ "DeepThink",
2289
+ "Deep Think"
2290
+ ]);
2291
+ this.deepThink = enabled;
2292
+ return { ok: true };
2293
+ } catch (error) {
2294
+ return {
2295
+ ok: false,
2296
+ error: String(error)
2297
+ };
2298
+ }
2299
+ });
2300
+ }
2301
+ /** Toggle internet search on the web page (web label: 智能搜索). */
2302
+ async setSearch(enabled) {
2303
+ if (this.busy) return {
2304
+ ok: false,
2305
+ error: "正在生成回复,请先等待完成"
2306
+ };
2307
+ return this.queue.run(async () => {
2308
+ try {
2309
+ await this.ensureBrowser();
2310
+ if ((await this.readToggles()).search !== enabled) await this.clickToggle([
2311
+ "智能搜索",
2312
+ "联网搜索",
2313
+ "Search"
2314
+ ]);
2315
+ this.search = enabled;
2316
+ return { ok: true };
2317
+ } catch (error) {
2318
+ return {
2319
+ ok: false,
2320
+ error: String(error)
2321
+ };
2322
+ }
2323
+ });
2324
+ }
2325
+ /** Close the browser (releases the profile lock). */
2326
+ async disposeBrowser() {
2327
+ try {
2328
+ await this.context?.close();
2329
+ } catch {}
2330
+ this.context = void 0;
2331
+ this.page = void 0;
2332
+ this.loginMode = false;
2333
+ if (this.state !== "error") this.setState("stopped");
2334
+ }
2335
+ /** Engine snapshot for status routes and agent tools. */
2336
+ async status() {
2337
+ if (this.state === "ready" && !this.isPageAlive()) await this.ensureBrowser().catch(() => void 0);
2338
+ let loggedIn = await this.isLoggedIn();
2339
+ if (loggedIn === true) this.loggedInOnce = true;
2340
+ else if (loggedIn === false) this.loggedInOnce = false;
2341
+ else if (loggedIn === null && this.loggedInOnce) loggedIn = true;
2342
+ const toggles = await this.readToggles();
2343
+ this.deepThink = toggles.deepThink;
2344
+ this.search = toggles.search;
2345
+ return {
2346
+ engine: this.state,
2347
+ engineError: this.engineError,
2348
+ loggedIn,
2349
+ pageUrl: this.pageUrl(),
2350
+ deepThink: this.deepThink,
2351
+ search: this.search,
2352
+ busy: this.busy,
2353
+ lastError: this.lastError,
2354
+ lastErrorCode: this.lastErrorCode
2355
+ };
2356
+ }
2357
+ };
2358
+ /**
2359
+ * Minimal HTML fragment parser used to round-trip scraped `.ds-markdown`
2360
+ * innerHTML through htmlToMarkdown (the in-page evaluate returns HTML
2361
+ * strings; the converter consumes a light DOM-shaped object graph).
2362
+ */
2363
+ function parseMarkup(html) {
2364
+ return new MarkupParser(html).parse();
2365
+ }
2366
+ /** Tiny HTML tokenizer → light DOM graph (sufficient for DeepSeek's markdown HTML). */
2367
+ var MarkupParser = class {
2368
+ tokens;
2369
+ index = 0;
2370
+ constructor(html) {
2371
+ this.tokens = html.split(/(<[^>]+>)/).filter((token) => token !== "");
2372
+ }
2373
+ parse() {
2374
+ return this.parseChildren(void 0);
2375
+ }
2376
+ parseChildren(parent) {
2377
+ const node = {
2378
+ nodeType: 1,
2379
+ children: [],
2380
+ attributes: {},
2381
+ parent
2382
+ };
2383
+ while (this.index < this.tokens.length) {
2384
+ const token = this.tokens[this.index];
2385
+ if (!token.startsWith("<")) {
2386
+ node.children.push({
2387
+ nodeType: 3,
2388
+ textContent: token,
2389
+ children: [],
2390
+ attributes: {},
2391
+ parent: node
2392
+ });
2393
+ this.index++;
2394
+ continue;
2395
+ }
2396
+ const close = /^<\/([a-zA-Z0-9]+)>$/.exec(token);
2397
+ if (close !== null) {
2398
+ this.index++;
2399
+ if (close[1].toLowerCase() === (node.tagName ?? "").toLowerCase()) return node;
2400
+ continue;
2401
+ }
2402
+ const open = /^<([a-zA-Z0-9]+)((?:\s+[a-zA-Z0-9-]+(?:=(?:"[^"]*"|'[^']*'|[^\s>]*))?)*)\s*(\/?)>$/.exec(token);
2403
+ if (open === null) {
2404
+ this.index++;
2405
+ continue;
2406
+ }
2407
+ const [, rawTag, attrsRaw] = open;
2408
+ const tag = rawTag.toLowerCase();
2409
+ const attributes = {};
2410
+ if (attrsRaw !== void 0) {
2411
+ const attrRe = /([a-zA-Z0-9-]+)(?:=("[^"]*"|'[^']*'|[^\s>]*))?/g;
2412
+ let match;
2413
+ while ((match = attrRe.exec(attrsRaw)) !== null) {
2414
+ const value = match[2] === void 0 ? void 0 : match[2].replace(/^["']|["']$/g, "");
2415
+ attributes[match[1]] = value;
2416
+ }
2417
+ }
2418
+ this.index++;
2419
+ const element = {
2420
+ tagName: tag,
2421
+ nodeType: 1,
2422
+ children: [],
2423
+ attributes,
2424
+ parent
2425
+ };
2426
+ if (!open[3].endsWith("/")) {
2427
+ const child = this.parseChildren(element);
2428
+ for (const grandchild of child.children) element.children.push(grandchild);
2429
+ }
2430
+ node.children.push(element);
2431
+ }
2432
+ return node;
2433
+ }
2434
+ };
2435
+ //#endregion
2436
+ //#region src/transfer.ts
2437
+ /**
2438
+ * Harness-mode transfer: turn a web-chat transcript into development context.
2439
+ *
2440
+ * This is the "Continue in Codex" / ChatGPT-mode analog, and like Codex it is
2441
+ * a CONTEXT HANDOFF rather than a raw replay: the exploration-phase web
2442
+ * conversation is distilled (via the harness LLM) into an executable task
2443
+ * brief — the execution-phase state representation the agent actually needs —
2444
+ * and that brief seeds a fresh harness session. The raw transcript is kept as
2445
+ * the fallback when distillation is unavailable.
2446
+ *
2447
+ * Two targets:
2448
+ * - new harness session — a COLD persisted session seeded with the distilled
2449
+ * brief (or raw transcript), so it shows up in the GUI list and resumes;
2450
+ * - workspace file — the raw transcript rendered to markdown in the target
2451
+ * project directory, so any agent can read it with file tools.
2452
+ *
2453
+ * Session creation writes directly through the session-persistence backend
2454
+ * (`sessionPersistence.create` + `append`), NOT `ctx.sessions.create()`:
2455
+ * the store's `create` produces a LIVE session owned by the calling fiber,
2456
+ * which the GUI then refuses to resume ("cannot prepare … while it is live").
2457
+ * A cold persisted session is exactly what the GUI's resume path expects.
2458
+ */
2459
+ /** Role label used in rendered transcripts. */
2460
+ const ROLE_LABEL = {
2461
+ user: "用户",
2462
+ assistant: "DeepSeek(网页端)"
2463
+ };
2464
+ /** Remove the collapsible R1 reasoning block(s) from reply markdown. */
2465
+ function stripThinking(markdown) {
2466
+ return markdown.replace(/<details>\s*<summary>.*?<\/summary>[\s\S]*?<\/details>/g, "").replace(/\n{3,}/g, "\n\n").trim();
2467
+ }
2468
+ /** Render the message list of a transcript (no header) to markdown. */
2469
+ function renderMessagesMarkdown(messages, options) {
2470
+ const excludeThinking = options?.excludeThinking ?? false;
2471
+ const lines = [];
2472
+ for (const message of messages) {
2473
+ if (message.role === "assistant" && message.streaming) continue;
2474
+ const content = (excludeThinking ? stripThinking(message.content) : message.content).trim();
2475
+ lines.push(`## ${ROLE_LABEL[message.role]}`);
2476
+ lines.push("");
2477
+ lines.push(content === "" ? "(无内容)" : content);
2478
+ if (message.attachments !== void 0 && message.attachments.length > 0) {
2479
+ lines.push("");
2480
+ lines.push(`> 📎 图片附件:${message.attachments.join("、")}`);
2481
+ }
2482
+ if (message.error !== void 0) {
2483
+ lines.push("");
2484
+ lines.push(`> ⚠️ 该条回复可能不完整:${message.error}`);
2485
+ }
2486
+ lines.push("");
2487
+ }
2488
+ return lines.join("\n").trim();
2489
+ }
2490
+ /** Render one transcript to markdown for harness consumption. */
2491
+ function renderTranscriptMarkdown(transcript, options) {
2492
+ const lines = [];
2493
+ lines.push(`# 网页端对话记录:${transcript.title}`);
2494
+ lines.push("");
2495
+ lines.push(`- 来源:DeepSeek 网页端(chat.deepseek.com)· 模型 ${transcript.model}`);
2496
+ lines.push(`- 开始时间:${new Date(transcript.createdAt).toLocaleString()}`);
2497
+ lines.push(`- 消息数:${transcript.messages.length}`);
2498
+ lines.push("");
2499
+ lines.push("> 以下内容由 dsh-webchat 插件从 DeepSeek 网页端会话导出。");
2500
+ lines.push("");
2501
+ const body = renderMessagesMarkdown(transcript.messages, options);
2502
+ if (body !== "") lines.push(body);
2503
+ return lines.join("\n").trim() + "\n";
2504
+ }
2505
+ /**
2506
+ * Framing that turns the distilled brief into established context for the
2507
+ * agent — the analog of Codex's "another model started to solve this problem
2508
+ * and produced a summary; use it to build on the work already done".
2509
+ */
2510
+ const HANDOFF_PREAMBLE = "这是一次从 DeepSeek 网页端会话(chat.deepseek.com)转来的上下文交接。下面的任务简报已把该对话提炼为可执行的任务上下文——把它当作既定目标与背景,直接在其基础上继续,不要复述。";
2511
+ /**
2512
+ * The distillation directive. Delivered as the final user message after the
2513
+ * raw transcript so the model condenses the exploration phase into the
2514
+ * execution-phase state representation. Mirrors Codex's handoff and DSH's
2515
+ * compaction checkpoint structure, tuned for "web chat → coding task".
2516
+ */
2517
+ const DISTILL_INSTRUCTION = [
2518
+ "You are distilling a web-chat conversation (a user exploring and planning with a DeepSeek web model) into an executable task brief for a coding agent that will resume this work in a FRESH session WITHOUT the raw conversation.",
2519
+ "",
2520
+ "Output EXACTLY the Markdown structure below — every section, in order, terse bullets, \"(none)\" for an empty section:",
2521
+ "",
2522
+ "## Objective",
2523
+ "- [the concrete goal/task to execute; quote the user's exact wording where it matters]",
2524
+ "",
2525
+ "## Established Context",
2526
+ "- [decisions, constraints, requirements, and facts already settled]",
2527
+ "",
2528
+ "## Current State",
2529
+ "- [what has been designed, decided, or produced so far]",
2530
+ "",
2531
+ "## Next Steps",
2532
+ "- [concrete ordered actions the coding agent should take]",
2533
+ "",
2534
+ "## Open Questions & Risks",
2535
+ "- [anything unresolved, uncertain, or risky]",
2536
+ "",
2537
+ "Rules:",
2538
+ "- Terse, concrete engineering prose. Preserve exact identifiers, paths, commands, error strings, code snippets, and numeric values.",
2539
+ "- Do not invent facts; mark uncertainty explicitly.",
2540
+ "- Do not mention this distillation request or the web-chat source.",
2541
+ "- Output only the brief."
2542
+ ].join("\n");
2543
+ /** Default output-token cap for the final distillation brief. */
2544
+ const DEFAULT_TRANSFER_MAX_TOKENS = 4096;
2545
+ /** Default output-token cap for one chunk summary in the map phase. */
2546
+ const DEFAULT_TRANSFER_CHUNK_TOKENS = 1024;
2547
+ /** Character budget per chunk when splitting a long transcript for map-reduce. */
2548
+ const CHUNK_CHAR_BUDGET = 12e3;
2549
+ /** Pick a provider/model for the one-shot distillation call. */
2550
+ async function resolveDistillTarget(llm, provider, model) {
2551
+ if (provider !== "" && model !== "") return {
2552
+ provider,
2553
+ model
2554
+ };
2555
+ const providers = llm.listProviders();
2556
+ if (providers.length === 0) return void 0;
2557
+ const baseProvider = provider !== "" ? provider : (providers.find((entry) => entry.id.toLowerCase().includes("deepseek")) ?? providers[0]).id;
2558
+ if (model !== "") return {
2559
+ provider: baseProvider,
2560
+ model
2561
+ };
2562
+ const models = await llm.listModels(baseProvider);
2563
+ const picked = models.find((entry) => entry.id.toLowerCase().includes("chat")) ?? models[0];
2564
+ return picked === void 0 ? void 0 : {
2565
+ provider: baseProvider,
2566
+ model: picked.id
2567
+ };
2568
+ }
2569
+ /**
2570
+ * Split a transcript's messages into chunks of at most `budget` characters
2571
+ * (sum of `message.content.length`), never splitting a single message: a
2572
+ * message larger than the budget becomes its own (oversized) chunk. Streaming
2573
+ * assistant messages are skipped (they were never completed).
2574
+ */
2575
+ function chunkTranscript(transcript, budget = CHUNK_CHAR_BUDGET) {
2576
+ const chunks = [];
2577
+ let current = [];
2578
+ let size = 0;
2579
+ for (const message of transcript.messages) {
2580
+ if (message.role === "assistant" && message.streaming) continue;
2581
+ const messageSize = Math.max(1, message.content.length);
2582
+ if (current.length > 0 && size + messageSize > budget) {
2583
+ chunks.push(current);
2584
+ current = [];
2585
+ size = 0;
2586
+ }
2587
+ current.push(message);
2588
+ size += messageSize;
2589
+ }
2590
+ if (current.length > 0) chunks.push(current);
2591
+ return chunks;
2592
+ }
2593
+ /**
2594
+ * The map-phase directive: condense one slice of a long conversation into
2595
+ * dense notes a later synthesis step merges. Terse, factual, uncertainty-marked.
2596
+ */
2597
+ const CHUNK_SUMMARY_INSTRUCTION = [
2598
+ "You are condensing one slice of a long web-chat conversation (a user exploring and planning with a DeepSeek web model) into dense notes that a later synthesis step will merge into a task brief.",
2599
+ "",
2600
+ "Preserve exactly, and do not invent:",
2601
+ "- decisions, constraints, requirements, and settled facts",
2602
+ "- exact identifiers, paths, commands, error strings, code snippets, and numeric values",
2603
+ "- anything still open, uncertain, or risky",
2604
+ "",
2605
+ "Output compact markdown notes. Mark uncertainty explicitly. Do not mention this summarization request or the source. Output only the notes."
2606
+ ].join("\n");
2607
+ /** Run one LLM distillation call and return the assembled text (undefined on failure). */
2608
+ async function runDistillCall(llm, target, instruction, maxTokens) {
2609
+ const assembler = new BlockAssembler();
2610
+ try {
2611
+ for await (const chunk of llm.stream({
2612
+ provider: target.provider,
2613
+ model: target.model,
2614
+ messages: [createUserMessage({
2615
+ content: [{
2616
+ type: "text",
2617
+ text: instruction
2618
+ }],
2619
+ source: {
2620
+ kind: "plugin",
2621
+ plugin: "webchat"
2622
+ }
2623
+ })],
2624
+ maxTokens,
2625
+ purpose: "compaction"
2626
+ })) assembler.push(chunk);
2627
+ } catch {
2628
+ return;
2629
+ }
2630
+ const finish = assembler.finish;
2631
+ if (finish.kind !== "stop" && finish.kind !== "max-tokens") return void 0;
2632
+ const text = assembler.blocks().filter((block) => block.type === "text").map((block) => block.text).join("\n").trim();
2633
+ return text === "" ? void 0 : text;
2634
+ }
2635
+ /**
2636
+ * Distill a web transcript into an executable task brief via the harness LLM.
2637
+ * Long transcripts are distilled with map-reduce: each chunk is summarized
2638
+ * (map, capped at `chunkTokens`), then the summaries are merged into the final
2639
+ * brief (reduce, capped at `maxTokens`). Short transcripts take a single shot.
2640
+ * Returns undefined (so callers fall back to the raw transcript) when the LLM
2641
+ * service, a provider/model, or a clean completion is unavailable.
2642
+ */
2643
+ async function distillTranscriptToBrief(ctx, transcript, config) {
2644
+ const llm = ctx.get("llm");
2645
+ if (llm === void 0) return void 0;
2646
+ const target = await resolveDistillTarget(llm, config.provider, config.model).catch(() => void 0);
2647
+ if (target === void 0) return void 0;
2648
+ const maxTokens = config.maxTokens !== void 0 && config.maxTokens > 0 ? config.maxTokens : DEFAULT_TRANSFER_MAX_TOKENS;
2649
+ const chunkTokens = config.chunkTokens !== void 0 && config.chunkTokens > 0 ? config.chunkTokens : DEFAULT_TRANSFER_CHUNK_TOKENS;
2650
+ const chunks = chunkTranscript(transcript);
2651
+ let source;
2652
+ if (chunks.length <= 1) source = `${DISTILL_INSTRUCTION}\n\n--- 网页对话记录 ---\n\n${renderTranscriptMarkdown(transcript, { excludeThinking: true })}`;
2653
+ else {
2654
+ const summaries = [];
2655
+ for (let i = 0; i < chunks.length; i++) {
2656
+ const chunkMarkdown = renderMessagesMarkdown(chunks[i], { excludeThinking: true });
2657
+ const summary = await runDistillCall(llm, target, `${CHUNK_SUMMARY_INSTRUCTION}\n\n--- 片段 ${i + 1} / ${chunks.length} ---\n\n${chunkMarkdown}`, chunkTokens);
2658
+ summaries.push(summary ?? `(片段 ${i + 1} 摘要失败,截取原文)\n${chunkMarkdown.slice(0, 12e3)}`);
2659
+ }
2660
+ source = `${DISTILL_INSTRUCTION}\n\n--- 网页对话片段摘要(共 ${chunks.length} 段,已按段摘要) ---\n\n${summaries.join("\n\n---\n\n")}`;
2661
+ }
2662
+ const brief = await runDistillCall(llm, target, source, maxTokens);
2663
+ if (brief === void 0) return void 0;
2664
+ return {
2665
+ brief,
2666
+ provider: target.provider,
2667
+ model: target.model
2668
+ };
2669
+ }
2670
+ /** Build a user-message surface event carrying the handoff text at a given seq. */
2671
+ function transcriptUserMessageEvent(markdown, seq) {
2672
+ return {
2673
+ type: "user/message",
2674
+ seq,
2675
+ time: Date.now(),
2676
+ surfaceOp: "append",
2677
+ data: {
2678
+ id: randomUUID(),
2679
+ role: "user",
2680
+ content: [{
2681
+ type: "text",
2682
+ text: markdown
2683
+ }],
2684
+ source: {
2685
+ kind: "plugin",
2686
+ plugin: "webchat"
2687
+ }
2688
+ }
2689
+ };
2690
+ }
2691
+ /** Build the seed user-message event carrying the handoff text (seq 0). */
2692
+ function transcriptSeedEvent(markdown) {
2693
+ return transcriptUserMessageEvent(markdown, 0);
2694
+ }
2695
+ /**
2696
+ * Sanitize a web-chat title into a safe single-line session title (the host
2697
+ * session-title service strips control characters and collapses whitespace;
2698
+ * mirror that lightly so a transferred title never overflows the fold).
2699
+ */
2700
+ function normalizeSessionTitleText(text) {
2701
+ const cleaned = text.replace(/[\u0000-\u001F\u007F-\u009F]/g, "").replace(/\s+/g, " ").trim();
2702
+ return Array.from(cleaned).slice(0, 80).join("");
2703
+ }
2704
+ /**
2705
+ * Build the durable `session/title` event that pins the transferred session's
2706
+ * display name to the web chat's title. The `session/title` type is a
2707
+ * plugin-merged extension of `SessionEventMap` (from dsh-session-title), so it
2708
+ * is not in this package's compiled `SessionEvent` union — cast through
2709
+ * `unknown`. `source.kind: 'user'` pins the title against auto-regeneration.
2710
+ */
2711
+ function transcriptTitleEvent(title, seq, time) {
2712
+ return {
2713
+ type: "session/title",
2714
+ seq,
2715
+ time,
2716
+ data: {
2717
+ title: normalizeSessionTitleText(title),
2718
+ messageSeqs: [],
2719
+ source: { kind: "user" }
2720
+ }
2721
+ };
2722
+ }
2723
+ /** Validate/normalize a workspace directory (must be absolute). */
2724
+ function normalizeCwd(cwd) {
2725
+ const resolved = cwd === void 0 || cwd === "" ? process.cwd() : cwd;
2726
+ if (!resolved.startsWith("/") && !/^[A-Za-z]:[\\/]/.test(resolved)) throw new Error(`cwd 必须是绝对路径,收到: ${resolved}`);
2727
+ return resolved;
2728
+ }
2729
+ /** Access the optional workspace registry without a hard service dependency. */
2730
+ function workspaceRegistryOf(ctx) {
2731
+ return ctx.get("workspaceRegistry");
2732
+ }
2733
+ /**
2734
+ * Resolve the transfer destination. A `workspace.workspaceId` is validated
2735
+ * BEFORE the session is persisted so an unknown id fails fast instead of
2736
+ * leaving an orphan ungrouped session; `workspace.path` is realpath-canonicalized
2737
+ * (an existing directory) and opportunistically resolved to a workspace; with
2738
+ * no workspace the existing `cwd` behavior applies unchanged.
2739
+ */
2740
+ async function resolveTransferTarget(ctx, input) {
2741
+ const registry = workspaceRegistryOf(ctx);
2742
+ const target = input.workspace;
2743
+ if (target?.workspaceId !== void 0 && target.workspaceId !== "") {
2744
+ if (registry === void 0) throw new Error(`无法归入工作区 ${target.workspaceId}:当前部署未挂载工作区服务`);
2745
+ const workspace = registry.get(target.workspaceId);
2746
+ if (workspace === void 0) throw new Error(`工作区 ${target.workspaceId} 不存在或已删除`);
2747
+ return {
2748
+ cwd: workspace.path,
2749
+ workspace
2750
+ };
2751
+ }
2752
+ if (target?.path !== void 0 && target.path !== "") {
2753
+ let cwd;
2754
+ try {
2755
+ cwd = await realpath(target.path);
2756
+ } catch {
2757
+ throw new Error(`工作区路径不可用(不存在或不是目录):${target.path}`);
2758
+ }
2759
+ const workspace = registry === void 0 ? void 0 : await registry.resolveByPath(cwd).catch(() => void 0);
2760
+ return {
2761
+ cwd,
2762
+ workspace
2763
+ };
2764
+ }
2765
+ return { cwd: normalizeCwd(input.cwd) };
2766
+ }
2767
+ /**
2768
+ * Continue an EXISTING harness session by appending the handoff as a fresh
2769
+ * user message — the "same task, another web round" resume path. The session's
2770
+ * stored log is loaded (which also durably closes any crash-orphaned turn) to
2771
+ * learn the next contiguous seq and turn number, then an open turn is appended
2772
+ * carrying the message; the resume loop closes the open turn and claims the
2773
+ * message as pending input, exactly like a queued user prompt. No new session
2774
+ * or header is created, so the target keeps its cwd/title/workspace.
2775
+ */
2776
+ async function appendToExistingSession(ctx, sessionId, markdown, distilled) {
2777
+ const persistence = ctx.get("sessionPersistence");
2778
+ if (persistence === void 0) throw new Error("未找到会话持久化后端,无法延续已有会话");
2779
+ const id = SessionId(sessionId);
2780
+ // dsh >= 0.2 addresses storage through a per-session handle: opening for
2781
+ // write carries both the read and the append. A session the GUI already
2782
+ // holds refuses a second writer, so that case is reported as actionable.
2783
+ let handle;
2784
+ try {
2785
+ handle = await persistence.open(id, "write");
2786
+ } catch (error) {
2787
+ const reason = error instanceof Error ? error.message : String(error);
2788
+ throw new Error(`无法以写入方式打开该会话(它可能已在界面上打开并持有写租约):${reason}`);
2789
+ }
2790
+ try {
2791
+ const inspection = await handle.read(0);
2792
+ let nextSeq = 0;
2793
+ let maxTurn = 0;
2794
+ for (const event of inspection.events) {
2795
+ if (event.seq >= nextSeq) nextSeq = event.seq + 1;
2796
+ const turn = event.data?.turn;
2797
+ if (typeof turn === "number" && turn > maxTurn) maxTurn = turn;
2798
+ }
2799
+ const turn = maxTurn + 1;
2800
+ const now = Date.now();
2801
+ const appended = [
2802
+ {
2803
+ type: "turn/start",
2804
+ seq: nextSeq,
2805
+ time: now,
2806
+ data: { turn }
2807
+ },
2808
+ {
2809
+ type: "step/start",
2810
+ seq: nextSeq + 1,
2811
+ time: now,
2812
+ data: {
2813
+ turn,
2814
+ step: 1
2815
+ }
2816
+ },
2817
+ transcriptUserMessageEvent(markdown, nextSeq + 2)
2818
+ ];
2819
+ await handle.append(appended);
2820
+ if (typeof handle.flush === "function") await handle.flush();
2821
+ } finally {
2822
+ if (typeof handle.close === "function") await handle.close();
2823
+ }
2824
+ return {
2825
+ sessionId,
2826
+ distilled,
2827
+ attached: false
2828
+ };
2829
+ }
2830
+ /**
2831
+ * Create a new COLD harness session seeded with a distilled task brief (or the
2832
+ * raw transcript when the user chooses 'raw' / distillation is unavailable),
2833
+ * written straight through the session-persistence backend so the GUI lists it
2834
+ * and can resume it later (no live-store ownership). When `input.workspace`
2835
+ * names a registered workspace, the session's cwd is set to that workspace's
2836
+ * canonical path and the session is attached to the workspace's account, so
2837
+ * the GUI groups it under that workspace instead of "ungrouped".
2838
+ *
2839
+ * `mode` is the user's explicit choice; when undefined the plugin config
2840
+ * default (`transferDistill`) applies.
2841
+ */
2842
+ async function transferToHarnessSession(ctx, input, config, mode) {
2843
+ const rawMarkdown = renderTranscriptMarkdown(input.transcript, { excludeThinking: true });
2844
+ const shouldDistill = mode === "distill" ? true : mode === "raw" ? false : config.distill;
2845
+ let seedMarkdown = `${HANDOFF_PREAMBLE}\n\n${rawMarkdown}`;
2846
+ let distilled = false;
2847
+ if (shouldDistill) {
2848
+ const result = await distillTranscriptToBrief(ctx, input.transcript, config);
2849
+ if (result !== void 0) {
2850
+ seedMarkdown = `${HANDOFF_PREAMBLE}\n\n${result.brief}\n\n> (已由 ${result.provider}/${result.model} 从网页对话蒸馏生成)`;
2851
+ distilled = true;
2852
+ }
2853
+ }
2854
+ if (input.targetSessionId !== void 0 && input.targetSessionId !== "") return appendToExistingSession(ctx, input.targetSessionId, seedMarkdown, distilled);
2855
+ const target = await resolveTransferTarget(ctx, input);
2856
+ const id = SessionId(`session-${randomUUID()}`);
2857
+ const header = {
2858
+ version: SESSION_FORMAT_VERSION,
2859
+ id,
2860
+ createdAt: Date.now(),
2861
+ cwd: target.cwd,
2862
+ delegationDepth: 0
2863
+ };
2864
+ const seedEvent = transcriptSeedEvent(seedMarkdown);
2865
+ const title = normalizeSessionTitleText(input.transcript.title);
2866
+ const events = [seedEvent];
2867
+ if (title !== "") events.push(transcriptTitleEvent(title, seedEvent.seq + 1, seedEvent.time));
2868
+ const persistence = ctx.get("sessionPersistence");
2869
+ if (persistence !== void 0) {
2870
+ // Cold path: create the stored session, write the seed + title events
2871
+ // through its write handle, then materialize them so the GUI and other
2872
+ // processes list the session and can resume it.
2873
+ const handle = await persistence.create(header);
2874
+ try {
2875
+ await handle.append(events);
2876
+ if (typeof handle.flush === "function") await handle.flush();
2877
+ } finally {
2878
+ if (typeof handle.close === "function") await handle.close();
2879
+ }
2880
+ } else ctx.sessions.create(id, {
2881
+ meta: { cwd: target.cwd },
2882
+ seed: events
2883
+ });
2884
+ let attached = false;
2885
+ if (target.workspace !== void 0) try {
2886
+ await target.workspace.attachSession(id);
2887
+ attached = true;
2888
+ } catch {
2889
+ attached = false;
2890
+ }
2891
+ const workspaceId = attached && target.workspace !== void 0 ? target.workspace.id : void 0;
2892
+ return {
2893
+ sessionId: id,
2894
+ distilled,
2895
+ attached,
2896
+ workspaceId
2897
+ };
2898
+ }
2899
+ /** Write the transcript markdown into the target directory; returns the path. */
2900
+ function exportTranscriptFile(input) {
2901
+ const cwd = normalizeCwd(input.cwd);
2902
+ const filePath = join(cwd, `webchat-${input.transcript.title.replace(/[^\w\u4e00-\u9fa5-]+/g, "-").replace(/-+/g, "-").slice(0, 60) || "webchat"}-${input.transcript.id.slice(-6)}.md`);
2903
+ mkdirSync(cwd, { recursive: true });
2904
+ writeFileSync(filePath, renderTranscriptMarkdown(input.transcript), "utf8");
2905
+ return { filePath: basename(filePath) };
2906
+ }
2907
+ //#endregion
2908
+ //#region src/routes.ts
2909
+ /** Cap on JSON request bodies (chat ops are small). */
2910
+ const MAX_JSON_BODY_BYTES = 64 * 1024;
2911
+ /** Loopback-only trust fence (mirrors dsh-ssh). */
2912
+ function isLoopbackRequest(req) {
2913
+ const host = req.headers.host ?? "";
2914
+ const address = req.socket.remoteAddress ?? "";
2915
+ return (host.startsWith("127.0.0.1") || host.startsWith("localhost") || host.startsWith("[::1]")) && (address === "127.0.0.1" || address === "::1" || address === "::ffff:127.0.0.1" || address === void 0);
2916
+ }
2917
+ /** One JSON response. */
2918
+ function writeJson(res, status, body) {
2919
+ const payload = JSON.stringify(body);
2920
+ res.writeHead(status, {
2921
+ "content-type": "application/json; charset=utf-8",
2922
+ "referrer-policy": "no-referrer"
2923
+ });
2924
+ res.end(payload);
2925
+ }
2926
+ /** Read a JSON request body. */
2927
+ async function readJsonBody(req) {
2928
+ const chunks = [];
2929
+ let size = 0;
2930
+ for await (const chunk of req) {
2931
+ const buffer = chunk;
2932
+ size += buffer.length;
2933
+ if (size > MAX_JSON_BODY_BYTES) return void 0;
2934
+ chunks.push(buffer);
2935
+ }
2936
+ try {
2937
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
2938
+ return typeof parsed === "object" && parsed !== null ? parsed : void 0;
2939
+ } catch {
2940
+ return;
2941
+ }
2942
+ }
2943
+ function stringField(body, name) {
2944
+ const value = body?.[name];
2945
+ return typeof value === "string" && value !== "" ? value : void 0;
2946
+ }
2947
+ /** Build every /api/dsh-webchat route. */
2948
+ function makeRoutes(deps) {
2949
+ const { ctx, engine, store, distill } = deps;
2950
+ const guard = (req, res) => {
2951
+ if (isLoopbackRequest(req)) return true;
2952
+ writeJson(res, 403, {
2953
+ ok: false,
2954
+ error: "loopback only"
2955
+ });
2956
+ return false;
2957
+ };
2958
+ const stateView = async () => {
2959
+ const status = await engine.status();
2960
+ return {
2961
+ ok: true,
2962
+ engine: status.engine,
2963
+ engineError: status.engineError,
2964
+ loggedIn: status.loggedIn,
2965
+ pageUrl: status.pageUrl,
2966
+ deepThink: status.deepThink,
2967
+ search: status.search,
2968
+ busy: status.busy,
2969
+ lastError: status.lastError,
2970
+ lastErrorCode: status.lastErrorCode,
2971
+ activeChatId: store.activeChat()?.id,
2972
+ chats: store.list()
2973
+ };
2974
+ };
2975
+ return [
2976
+ {
2977
+ kind: "exact",
2978
+ path: "/api/dsh-webchat/state",
2979
+ handler: async (req, res) => {
2980
+ if (!guard(req, res)) return;
2981
+ writeJson(res, 200, await stateView());
2982
+ }
2983
+ },
2984
+ {
2985
+ kind: "exact",
2986
+ path: "/api/dsh-webchat/open-login",
2987
+ handler: async (req, res) => {
2988
+ if (!guard(req, res)) return;
2989
+ const result = await engine.openLoginWindow();
2990
+ writeJson(res, result.ok ? 200 : 500, {
2991
+ ok: result.ok,
2992
+ error: result.error
2993
+ });
2994
+ }
2995
+ },
2996
+ {
2997
+ kind: "exact",
2998
+ path: "/api/dsh-webchat/close-browser",
2999
+ handler: async (req, res) => {
3000
+ if (!guard(req, res)) return;
3001
+ await engine.disposeBrowser();
3002
+ writeJson(res, 200, { ok: true });
3003
+ }
3004
+ },
3005
+ {
3006
+ kind: "exact",
3007
+ path: "/api/dsh-webchat/new-chat",
3008
+ handler: async (req, res) => {
3009
+ if (!guard(req, res)) return;
3010
+ const result = await engine.newChat();
3011
+ writeJson(res, result.ok ? 200 : 500, result);
3012
+ }
3013
+ },
3014
+ {
3015
+ kind: "exact",
3016
+ path: "/api/dsh-webchat/send",
3017
+ handler: async (req, res) => {
3018
+ if (!guard(req, res)) return;
3019
+ const body = await readJsonBody(req);
3020
+ const text = stringField(body, "text");
3021
+ if (text === void 0) {
3022
+ writeJson(res, 400, {
3023
+ ok: false,
3024
+ error: "缺少 text 字段"
3025
+ });
3026
+ return;
3027
+ }
3028
+ const images = Array.isArray(body?.["images"]) ? body["images"].filter((value) => typeof value === "string").map((value) => value) : void 0;
3029
+ const result = await engine.send(text, false, images);
3030
+ writeJson(res, result.ok ? 200 : 500, result);
3031
+ }
3032
+ },
3033
+ {
3034
+ kind: "exact",
3035
+ path: "/api/dsh-webchat/stop",
3036
+ handler: async (req, res) => {
3037
+ if (!guard(req, res)) return;
3038
+ await engine.stop();
3039
+ writeJson(res, 200, { ok: true });
3040
+ }
3041
+ },
3042
+ {
3043
+ kind: "exact",
3044
+ path: "/api/dsh-webchat/deep-think",
3045
+ handler: async (req, res) => {
3046
+ if (!guard(req, res)) return;
3047
+ const body = await readJsonBody(req);
3048
+ const enabled = typeof body?.["enabled"] === "boolean" ? body["enabled"] : void 0;
3049
+ if (enabled === void 0) {
3050
+ writeJson(res, 400, {
3051
+ ok: false,
3052
+ error: "缺少 enabled 字段"
3053
+ });
3054
+ return;
3055
+ }
3056
+ const result = await engine.setDeepThink(enabled);
3057
+ writeJson(res, result.ok ? 200 : 500, result);
3058
+ }
3059
+ },
3060
+ {
3061
+ kind: "exact",
3062
+ path: "/api/dsh-webchat/search",
3063
+ handler: async (req, res) => {
3064
+ if (!guard(req, res)) return;
3065
+ const body = await readJsonBody(req);
3066
+ const enabled = typeof body?.["enabled"] === "boolean" ? body["enabled"] : void 0;
3067
+ if (enabled === void 0) {
3068
+ writeJson(res, 400, {
3069
+ ok: false,
3070
+ error: "缺少 enabled 字段"
3071
+ });
3072
+ return;
3073
+ }
3074
+ const result = await engine.setSearch(enabled);
3075
+ writeJson(res, result.ok ? 200 : 500, result);
3076
+ }
3077
+ },
3078
+ {
3079
+ kind: "exact",
3080
+ path: "/api/dsh-webchat/transfer",
3081
+ handler: async (req, res) => {
3082
+ if (!guard(req, res)) return;
3083
+ const body = await readJsonBody(req);
3084
+ const chatId = stringField(body, "chatId") ?? store.activeChat()?.id;
3085
+ const cwd = stringField(body, "cwd");
3086
+ const workspaceId = stringField(body, "workspaceId");
3087
+ const targetSessionId = stringField(body, "targetSessionId");
3088
+ const mode = body?.["mode"] === "raw" ? "raw" : body?.["mode"] === "distill" ? "distill" : void 0;
3089
+ const transcript = chatId === void 0 ? void 0 : store.getChat(chatId);
3090
+ if (transcript === void 0) {
3091
+ writeJson(res, 404, {
3092
+ ok: false,
3093
+ error: "找不到该对话记录"
3094
+ });
3095
+ return;
3096
+ }
3097
+ try {
3098
+ const { sessionId, distilled, attached, workspaceId: attachedWorkspaceId } = await transferToHarnessSession(ctx, {
3099
+ transcript,
3100
+ cwd,
3101
+ workspace: workspaceId === void 0 ? void 0 : { workspaceId },
3102
+ targetSessionId
3103
+ }, distill, mode);
3104
+ writeJson(res, 200, {
3105
+ ok: true,
3106
+ sessionId,
3107
+ distilled,
3108
+ attached,
3109
+ continued: targetSessionId !== void 0,
3110
+ workspaceId: attachedWorkspaceId
3111
+ });
3112
+ } catch (error) {
3113
+ writeJson(res, 500, {
3114
+ ok: false,
3115
+ error: String(error)
3116
+ });
3117
+ }
3118
+ }
3119
+ },
3120
+ {
3121
+ kind: "exact",
3122
+ path: "/api/dsh-webchat/export",
3123
+ handler: async (req, res) => {
3124
+ if (!guard(req, res)) return;
3125
+ const body = await readJsonBody(req);
3126
+ const chatId = stringField(body, "chatId") ?? store.activeChat()?.id;
3127
+ const cwd = stringField(body, "cwd");
3128
+ const transcript = chatId === void 0 ? void 0 : store.getChat(chatId);
3129
+ if (transcript === void 0) {
3130
+ writeJson(res, 404, {
3131
+ ok: false,
3132
+ error: "找不到该对话记录"
3133
+ });
3134
+ return;
3135
+ }
3136
+ try {
3137
+ const { filePath } = exportTranscriptFile({
3138
+ transcript,
3139
+ cwd
3140
+ });
3141
+ writeJson(res, 200, {
3142
+ ok: true,
3143
+ filePath
3144
+ });
3145
+ } catch (error) {
3146
+ writeJson(res, 500, {
3147
+ ok: false,
3148
+ error: String(error)
3149
+ });
3150
+ }
3151
+ }
3152
+ },
3153
+ {
3154
+ kind: "exact",
3155
+ path: "/api/dsh-webchat/web-chats",
3156
+ handler: async (req, res) => {
3157
+ if (!guard(req, res)) return;
3158
+ const web = await engine.listWebConversations();
3159
+ const localTitles = new Set(store.titles());
3160
+ writeJson(res, 200, {
3161
+ ok: true,
3162
+ web,
3163
+ missing: web.filter((item) => !localTitles.has(item.title)).map((item) => item.title)
3164
+ });
3165
+ }
3166
+ },
3167
+ {
3168
+ kind: "exact",
3169
+ path: "/api/dsh-webchat/recover",
3170
+ handler: async (req, res) => {
3171
+ if (!guard(req, res)) return;
3172
+ const title = stringField(await readJsonBody(req), "title");
3173
+ if (title === void 0) {
3174
+ writeJson(res, 400, {
3175
+ ok: false,
3176
+ error: "缺少 title 字段"
3177
+ });
3178
+ return;
3179
+ }
3180
+ const result = await engine.recoverWebConversation(title);
3181
+ writeJson(res, result.ok ? 200 : 500, result);
3182
+ }
3183
+ },
3184
+ {
3185
+ kind: "exact",
3186
+ path: "/api/dsh-webchat/rename",
3187
+ handler: async (req, res) => {
3188
+ if (!guard(req, res)) return;
3189
+ const body = await readJsonBody(req);
3190
+ const chatId = stringField(body, "chatId");
3191
+ const title = stringField(body, "title");
3192
+ if (chatId === void 0 || title === void 0) {
3193
+ writeJson(res, 400, {
3194
+ ok: false,
3195
+ error: "缺少 chatId 或 title 字段"
3196
+ });
3197
+ return;
3198
+ }
3199
+ if (store.renameChat(chatId, title) === void 0) writeJson(res, 404, {
3200
+ ok: false,
3201
+ error: "找不到该对话记录"
3202
+ });
3203
+ else writeJson(res, 200, { ok: true });
3204
+ }
3205
+ },
3206
+ {
3207
+ kind: "exact",
3208
+ path: "/api/dsh-webchat/delete",
3209
+ handler: async (req, res) => {
3210
+ if (!guard(req, res)) return;
3211
+ const chatId = stringField(await readJsonBody(req), "chatId");
3212
+ if (chatId === void 0) {
3213
+ writeJson(res, 400, {
3214
+ ok: false,
3215
+ error: "缺少 chatId 字段"
3216
+ });
3217
+ return;
3218
+ }
3219
+ if (!store.deleteChat(chatId)) writeJson(res, 404, {
3220
+ ok: false,
3221
+ error: "找不到该对话记录"
3222
+ });
3223
+ else writeJson(res, 200, { ok: true });
3224
+ }
3225
+ },
3226
+ {
3227
+ kind: "exact",
3228
+ path: "/api/dsh-webchat/clear",
3229
+ handler: async (req, res) => {
3230
+ if (!guard(req, res)) return;
3231
+ writeJson(res, 200, {
3232
+ ok: true,
3233
+ count: store.clearAllChats()
3234
+ });
3235
+ }
3236
+ }
3237
+ ];
3238
+ }
3239
+ //#endregion
3240
+ //#region src/store.ts
3241
+ /**
3242
+ * Local transcript store. Chats are persisted as one JSON file under the
3243
+ * plugin data dir (~/.dsh/dsh-webchat/transcripts.json by default) so web
3244
+ * conversations survive restarts and can be transferred into harness mode
3245
+ * at any time. Atomic writes (tmp + rename) keep a crash from corrupting
3246
+ * history.
3247
+ */
3248
+ /** Default plugin data directory (tests inject a sandbox root). */
3249
+ function defaultDataDir() {
3250
+ return join(process.env.DSH_HOME ?? process.env.HOME ?? ".", ".dsh", "dsh-webchat");
3251
+ }
3252
+ /**
3253
+ * JSON-file transcript store. All mutations are synchronous and persisted
3254
+ * immediately (chats are small); the engine and routes use this single
3255
+ * instance so GUI and agent tools always see the same history.
3256
+ */
3257
+ var TranscriptStore = class {
3258
+ dataDir;
3259
+ file;
3260
+ chats;
3261
+ activeChatId;
3262
+ constructor(options = {}) {
3263
+ this.dataDir = options.dataDir ?? defaultDataDir();
3264
+ this.file = join(this.dataDir, "transcripts.json");
3265
+ const loaded = this.read();
3266
+ this.chats = loaded.chats;
3267
+ this.activeChatId = loaded.activeChatId;
3268
+ if (this.activeChatId !== void 0 && !this.chats.some((chat) => chat.id === this.activeChatId)) this.activeChatId = this.chats.at(-1)?.id;
3269
+ }
3270
+ read() {
3271
+ try {
3272
+ const parsed = JSON.parse(readFileSync(this.file, "utf8"));
3273
+ const chats = Array.isArray(parsed.chats) ? parsed.chats : [];
3274
+ return {
3275
+ version: 1,
3276
+ activeChatId: typeof parsed.activeChatId === "string" ? parsed.activeChatId : void 0,
3277
+ chats: chats.filter((chat) => typeof chat?.id === "string" && Array.isArray(chat.messages))
3278
+ };
3279
+ } catch {
3280
+ return {
3281
+ version: 1,
3282
+ chats: []
3283
+ };
3284
+ }
3285
+ }
3286
+ persist() {
3287
+ mkdirSync(this.dataDir, {
3288
+ recursive: true,
3289
+ mode: 448
3290
+ });
3291
+ const payload = {
3292
+ version: 1,
3293
+ activeChatId: this.activeChatId,
3294
+ chats: this.chats
3295
+ };
3296
+ const tmp = `${this.file}.tmp`;
3297
+ writeFileSync(tmp, JSON.stringify(payload, null, 2), { mode: 384 });
3298
+ renameSync(tmp, this.file);
3299
+ }
3300
+ /** Create a fresh chat and make it active. */
3301
+ createChat(model) {
3302
+ const now = Date.now();
3303
+ const chat = {
3304
+ id: `chat-${now.toString(36)}-${randomUUID().slice(0, 6)}`,
3305
+ title: "新的对话",
3306
+ createdAt: now,
3307
+ updatedAt: now,
3308
+ model,
3309
+ messages: [],
3310
+ streaming: false
3311
+ };
3312
+ this.chats.unshift(chat);
3313
+ this.activeChatId = chat.id;
3314
+ this.persist();
3315
+ return chat;
3316
+ }
3317
+ /** All chats, newest first. */
3318
+ list() {
3319
+ return [...this.chats];
3320
+ }
3321
+ /** Local transcript titles (for matching against the web sidebar). */
3322
+ titles() {
3323
+ return this.chats.map((chat) => chat.title);
3324
+ }
3325
+ /**
3326
+ * Import a recovered web conversation as a local transcript. Dedups by exact
3327
+ * title (web conversations with the same title map to the existing chat), so
3328
+ * re-syncing is idempotent. Returns the chat plus whether it was newly created.
3329
+ */
3330
+ importTranscript(input) {
3331
+ const cleanTitle = input.title.trim().replace(/\s+/g, " ").slice(0, 80) || "新的对话";
3332
+ const existing = this.chats.find((chat) => chat.title === cleanTitle);
3333
+ if (existing !== void 0) {
3334
+ this.activeChatId = existing.id;
3335
+ return {
3336
+ chat: existing,
3337
+ created: false
3338
+ };
3339
+ }
3340
+ const now = Date.now();
3341
+ const chat = {
3342
+ id: `chat-${now.toString(36)}-${randomUUID().slice(0, 6)}`,
3343
+ title: cleanTitle,
3344
+ createdAt: now,
3345
+ updatedAt: now,
3346
+ model: input.model,
3347
+ messages: input.messages,
3348
+ streaming: false
3349
+ };
3350
+ this.chats.unshift(chat);
3351
+ this.activeChatId = chat.id;
3352
+ this.persist();
3353
+ return {
3354
+ chat,
3355
+ created: true
3356
+ };
3357
+ }
3358
+ /** The active chat, or undefined when none exists yet. */
3359
+ activeChat() {
3360
+ if (this.activeChatId === void 0) return void 0;
3361
+ return this.chats.find((chat) => chat.id === this.activeChatId);
3362
+ }
3363
+ /** Read one chat by id. */
3364
+ getChat(id) {
3365
+ return this.chats.find((chat) => chat.id === id);
3366
+ }
3367
+ /** Pick the active chat, creating one if none exists. */
3368
+ ensureActiveChat(model) {
3369
+ return this.activeChat() ?? this.createChat(model);
3370
+ }
3371
+ /** Set which chat is active. */
3372
+ setActiveChat(id) {
3373
+ if (!this.chats.some((chat) => chat.id === id)) return false;
3374
+ this.activeChatId = id;
3375
+ this.persist();
3376
+ return true;
3377
+ }
3378
+ /** Mutate the active (or named) chat and persist. */
3379
+ update(id, mutate) {
3380
+ const chat = this.chats.find((candidate) => candidate.id === id);
3381
+ if (chat === void 0) return void 0;
3382
+ mutate(chat);
3383
+ chat.updatedAt = Date.now();
3384
+ this.persist();
3385
+ return chat;
3386
+ }
3387
+ /** Append a message to a chat. */
3388
+ appendMessage(id, message) {
3389
+ return this.update(id, (chat) => {
3390
+ chat.messages.push(message);
3391
+ if (message.role === "assistant") chat.streaming = message.streaming ?? false;
3392
+ });
3393
+ }
3394
+ /** Replace (or insert) one message by id — used for streaming updates. */
3395
+ upsertMessage(id, message) {
3396
+ return this.update(id, (chat) => {
3397
+ const index = chat.messages.findIndex((candidate) => candidate.id === message.id);
3398
+ if (index >= 0) chat.messages[index] = message;
3399
+ else chat.messages.push(message);
3400
+ if (message.role === "assistant") chat.streaming = message.streaming ?? false;
3401
+ });
3402
+ }
3403
+ /** Mark the chat's streaming flag (assistant reply started/stopped). */
3404
+ setStreaming(id, streaming, model) {
3405
+ return this.update(id, (chat) => {
3406
+ chat.streaming = streaming;
3407
+ if (model !== void 0) chat.model = model;
3408
+ });
3409
+ }
3410
+ /** Rename a chat (used to pin a meaningful title after the first exchange). */
3411
+ renameChat(id, title) {
3412
+ const clean = title.trim().replace(/\s+/g, " ").slice(0, 80);
3413
+ if (clean === "") return void 0;
3414
+ return this.update(id, (chat) => {
3415
+ chat.title = clean;
3416
+ });
3417
+ }
3418
+ /** Delete one chat; a deleted active chat falls back to the newest remaining. */
3419
+ deleteChat(id) {
3420
+ const before = this.chats.length;
3421
+ this.chats = this.chats.filter((chat) => chat.id !== id);
3422
+ if (this.chats.length === before) return false;
3423
+ if (this.activeChatId === id) this.activeChatId = this.chats.at(0)?.id;
3424
+ this.persist();
3425
+ return true;
3426
+ }
3427
+ /** Delete every chat; returns the number removed. */
3428
+ clearAllChats() {
3429
+ const count = this.chats.length;
3430
+ if (count === 0) return 0;
3431
+ this.chats = [];
3432
+ this.activeChatId = void 0;
3433
+ this.persist();
3434
+ return count;
3435
+ }
3436
+ };
3437
+ //#endregion
3438
+ //#region src/tools.ts
3439
+ /** Actionable hint for a structured engine error code (surfaced to the agent). */
3440
+ function errorCodeHint(code) {
3441
+ switch (code) {
3442
+ case "NEED_LOGIN": return "需要先登录:请在插件面板点击「打开登录窗口」完成 DeepSeek 网页登录。";
3443
+ case "PAGE_CHANGED": return "页面/协议疑似改版:请升级 dsh-webchat 插件。";
3444
+ case "TIMEOUT": return "生成超时:可稍后重试。";
3445
+ case "NETWORK": return "网络/浏览器错误:请检查网络或浏览器是否可用。";
3446
+ default: return "";
3447
+ }
3448
+ }
3449
+ /** One text content block (the only render shape these tools emit). */
3450
+ function text(value) {
3451
+ return [{
3452
+ type: "text",
3453
+ text: value
3454
+ }];
3455
+ }
3456
+ /** Render the chat list compactly. */
3457
+ function renderChats(store) {
3458
+ const chats = store.list();
3459
+ if (chats.length === 0) return "还没有任何网页端对话记录";
3460
+ return chats.map((chat) => {
3461
+ const messages = chat.messages.length;
3462
+ const last = chat.messages.at(-1);
3463
+ const preview = last === void 0 ? "" : ` · 最后: ${last.content.replace(/\s+/g, " ").slice(0, 60)}`;
3464
+ return `${chat.id} | ${chat.title} | ${chat.model} | ${messages} 条消息 | ${new Date(chat.updatedAt).toLocaleString()}${preview}`;
3465
+ }).join("\n");
3466
+ }
3467
+ /** The engine-status tool. */
3468
+ function webChatStatusTool(engine, store, listWorkspaces) {
3469
+ return defineTool({
3470
+ name: "webchat_status",
3471
+ description: "Report the DeepSeek 网页端 (chat.deepseek.com) web-chat state: engine status, login state, active chat, stored transcripts, and the harness workspaces available as webchat_transfer targets. Triggers: webchat, deepseek 网页端, 网页聊天. Use before webchat_send to confirm login.",
3472
+ parameters: {},
3473
+ output: {
3474
+ schema: {
3475
+ type: "object",
3476
+ additionalProperties: false,
3477
+ properties: { report: {
3478
+ type: "string",
3479
+ required: true
3480
+ } }
3481
+ },
3482
+ render: (_args, value) => text(value.report ?? "")
3483
+ },
3484
+ async execute() {
3485
+ const status = await engine.status();
3486
+ const active = store.activeChat();
3487
+ const lines = [
3488
+ `engine: ${status.engine}${status.engineError !== void 0 ? ` (${status.engineError})` : ""}`,
3489
+ `loggedIn: ${String(status.loggedIn)}`,
3490
+ `pageUrl: ${status.pageUrl ?? "-"}`,
3491
+ `deepThink: ${String(status.deepThink)}`,
3492
+ `search: ${String(status.search)}`,
3493
+ `busy: ${String(status.busy)}`,
3494
+ `activeChat: ${active === void 0 ? "-" : `${active.id} (${active.title})`}`,
3495
+ `chats:\n${renderChats(store)}`
3496
+ ];
3497
+ const web = await engine.listWebConversations().catch(() => []);
3498
+ if (web.length > 0) {
3499
+ const localTitles = new Set(store.list().map((chat) => chat.title));
3500
+ const missing = web.filter((item) => !localTitles.has(item.title)).map((item) => item.title);
3501
+ lines.push(`webChats:\n${web.map((item) => ` - ${item.title}`).join("\n")}`);
3502
+ if (missing.length > 0) lines.push(`webChatsNotImported (use webchat_recover):\n${missing.map((title) => ` - ${title}`).join("\n")}`);
3503
+ }
3504
+ const workspaces = listWorkspaces?.();
3505
+ if (workspaces !== void 0) {
3506
+ lines.push("workspaces:");
3507
+ if (workspaces.length === 0) lines.push(" (none)");
3508
+ else for (const ws of workspaces) lines.push(` ${ws.id} | ${ws.title} | ${ws.path}`);
3509
+ }
3510
+ return { report: lines.join("\n") };
3511
+ }
3512
+ });
3513
+ }
3514
+ /** The send-via-web tool. */
3515
+ function webChatSendTool(engine) {
3516
+ return defineTool({
3517
+ name: "webchat_send",
3518
+ description: "Send one message through the DeepSeek 网页端 (chat.deepseek.com) using the web model — your web session, no API billing. The assistant reply streams until complete and returns as markdown. Optionally attach local image files (absolute paths) for multimodal prompts. Requires the user to have logged into the web chat once (webchat_status → loggedIn true). Best for asking the web model to explain/design/review; do not use for file operations. Triggers: 网页端提问, deepseek web, chatgpt mode.",
3519
+ parameters: {
3520
+ text: {
3521
+ type: "string",
3522
+ required: true,
3523
+ description: "The message to send to deepseek-chat on the web."
3524
+ },
3525
+ images: {
3526
+ type: "array",
3527
+ items: { type: "string" },
3528
+ description: "Optional local absolute paths of image files to attach (multimodal prompt)."
3529
+ }
3530
+ },
3531
+ output: {
3532
+ schema: {
3533
+ type: "object",
3534
+ additionalProperties: false,
3535
+ properties: {
3536
+ reply: {
3537
+ type: "string",
3538
+ required: true
3539
+ },
3540
+ error: { type: "string" },
3541
+ code: { type: "string" },
3542
+ partial: { type: "boolean" }
3543
+ }
3544
+ },
3545
+ render: (_args, value) => {
3546
+ const partial = value.partial === true;
3547
+ const hint = errorCodeHint(value.code);
3548
+ return text([
3549
+ `webchat_send: 已通过 DeepSeek 网页端发送并收到回复${partial ? "(生成可能不完整)" : ""}`,
3550
+ value.error !== void 0 ? `(注意:${value.error})` : "",
3551
+ hint !== "" ? `(${hint})` : "",
3552
+ "",
3553
+ "--- 网页端回复 ---",
3554
+ (value.reply ?? "").trim() === "" ? "(空回复)" : (value.reply ?? "").trim(),
3555
+ "--- 回复结束 ---",
3556
+ "",
3557
+ "会话已保存,可用 webchat_transfer 将整段对话转移到 harness 会话。"
3558
+ ].join("\n"));
3559
+ }
3560
+ },
3561
+ async execute(args) {
3562
+ const textValue = typeof args?.text === "string" ? args.text.trim() : "";
3563
+ if (textValue === "") return {
3564
+ reply: "",
3565
+ error: "缺少 text 参数",
3566
+ partial: false
3567
+ };
3568
+ const images = Array.isArray(args?.images) ? args.images.filter((value) => typeof value === "string").map((value) => value) : void 0;
3569
+ const result = await engine.send(textValue, true, images);
3570
+ return {
3571
+ reply: result.reply ?? "",
3572
+ error: result.error,
3573
+ code: result.code,
3574
+ partial: result.error !== void 0
3575
+ };
3576
+ }
3577
+ });
3578
+ }
3579
+ /** The web-conversation recover tool (sync web sidebar → local store). */
3580
+ function webChatRecoverTool(engine) {
3581
+ return defineTool({
3582
+ name: "webchat_recover",
3583
+ description: "Recover a DeepSeek 网页端 conversation into the local store so it can be imported/transferred. With no title, lists the web-side conversations. Triggers: 同步网页会话, 恢复网页对话, sync webchat.",
3584
+ parameters: { title: {
3585
+ type: "string",
3586
+ description: "Conversation title (from the web sidebar or webchat_status webChats list). Omit to list web conversations."
3587
+ } },
3588
+ output: {
3589
+ schema: {
3590
+ type: "object",
3591
+ additionalProperties: false,
3592
+ properties: { report: {
3593
+ type: "string",
3594
+ required: true
3595
+ } }
3596
+ },
3597
+ render: (_args, value) => text(value.report ?? "")
3598
+ },
3599
+ async execute(args) {
3600
+ if (typeof args?.title === "string" && args.title.trim() !== "") {
3601
+ const result = await engine.recoverWebConversation(args.title.trim());
3602
+ if (!result.ok) return { report: `webchat_recover: 恢复失败 — ${result.error ?? ""}` };
3603
+ const dedup = result.created === false ? "(本地已存在,未重复导入)" : "";
3604
+ return { report: `webchat_recover: 已恢复「${result.title ?? ""}」为本地对话 ${result.chatId ?? ""}${dedup}。可用 webchat_transfer 转移。` };
3605
+ }
3606
+ const web = await engine.listWebConversations().catch(() => []);
3607
+ if (web.length === 0) return { report: "webchat_recover: 未在网页端读到会话(可能未登录或页面已改版)" };
3608
+ return { report: web.map((item) => `- ${item.title}`).join("\n") };
3609
+ }
3610
+ });
3611
+ }
3612
+ /** The transcript import tool. */
3613
+ function webChatImportTool(store) {
3614
+ return defineTool({
3615
+ name: "webchat_import",
3616
+ description: "Import one stored DeepSeek 网页端 transcript as markdown so the agent can continue the discussion itself. Triggers: 读取网页对话, import webchat, 把网页聊天作为上下文. Use webchat_status to list chat ids first.",
3617
+ parameters: { chatId: {
3618
+ type: "string",
3619
+ description: "Transcript id (from webchat_status). Omit for the active chat."
3620
+ } },
3621
+ output: {
3622
+ schema: {
3623
+ type: "object",
3624
+ additionalProperties: false,
3625
+ properties: {
3626
+ transcript: {
3627
+ type: "string",
3628
+ required: true
3629
+ },
3630
+ error: { type: "string" }
3631
+ }
3632
+ },
3633
+ render: (_args, value) => {
3634
+ if (value.error !== void 0) return text(value.error);
3635
+ return text(value.transcript ?? "");
3636
+ }
3637
+ },
3638
+ async execute(args) {
3639
+ const chat = typeof args?.chatId === "string" ? store.getChat(args.chatId) : store.activeChat();
3640
+ if (chat === void 0) return {
3641
+ transcript: "",
3642
+ error: "webchat_import: 找不到对话记录(用 webchat_status 查看列表)"
3643
+ };
3644
+ return { transcript: renderTranscriptMarkdown(chat) };
3645
+ }
3646
+ });
3647
+ }
3648
+ /** The transfer tool (closes over the host context so it can create sessions). */
3649
+ function webChatTransferTool(hostCtx, store, distill) {
3650
+ return defineTool({
3651
+ name: "webchat_transfer",
3652
+ description: "Transfer a stored DeepSeek 网页端 transcript into harness mode: distills the web conversation into an executable task brief (goal, established context, current state, next steps) and creates a NEW harness session whose first message is that brief (not the raw chat log), OR appends it as a fresh user message to an EXISTING session via targetSessionId (continue the same task). Optionally target a workspace (workspaceId from webchat_status workspaces list) so the new session is grouped under it. Returns the (new or target) session id. Triggers: 转移到 harness, 转成开发会话, transfer webchat.",
3653
+ parameters: {
3654
+ chatId: {
3655
+ type: "string",
3656
+ description: "Transcript id (from webchat_status). Omit for the active chat."
3657
+ },
3658
+ targetSessionId: {
3659
+ type: "string",
3660
+ description: "Optional existing harness session id to CONTINUE (append the brief as a new user message) instead of creating a new session. Omit to create a new session."
3661
+ },
3662
+ workspaceId: {
3663
+ type: "string",
3664
+ description: "Optional target workspace id (from the workspaces list in webchat_status). Omit to leave the new session ungrouped. Ignored when targetSessionId is given."
3665
+ },
3666
+ cwd: {
3667
+ type: "string",
3668
+ description: "Optional absolute working directory for the new session; ignored when workspaceId or targetSessionId is given."
3669
+ }
3670
+ },
3671
+ output: {
3672
+ schema: {
3673
+ type: "object",
3674
+ additionalProperties: false,
3675
+ properties: {
3676
+ sessionId: {
3677
+ type: "string",
3678
+ required: true
3679
+ },
3680
+ distilled: { type: "boolean" },
3681
+ attached: { type: "boolean" },
3682
+ continued: { type: "boolean" },
3683
+ workspaceId: { type: "string" },
3684
+ error: { type: "string" }
3685
+ }
3686
+ },
3687
+ render: (_args, value) => {
3688
+ if (value.error !== void 0) return text(value.error);
3689
+ const note = value.distilled === true ? "(已蒸馏为任务简报)" : "(蒸馏不可用,已回退为原始对话记录)";
3690
+ if (value.continued === true) return text(`webchat_transfer: 已把网页对话作为新的用户消息延续到 harness 会话 ${value.sessionId ?? ""}${note}。请告知用户打开该会话继续开发。`);
3691
+ const where = value.workspaceId !== void 0 ? `已归入工作区 ${value.workspaceId}` : "未分组";
3692
+ return text(`webchat_transfer: 已创建新 harness 会话 ${value.sessionId ?? ""}${note}(${where})。请告知用户从侧边栏打开该会话继续开发。`);
3693
+ }
3694
+ },
3695
+ async execute(args) {
3696
+ const chat = typeof args?.chatId === "string" ? store.getChat(args.chatId) : store.activeChat();
3697
+ if (chat === void 0) return {
3698
+ sessionId: "",
3699
+ distilled: false,
3700
+ attached: false,
3701
+ error: "webchat_transfer: 找不到对话记录(用 webchat_status 查看列表)"
3702
+ };
3703
+ const targetSessionId = typeof args?.targetSessionId === "string" && args.targetSessionId !== "" ? args.targetSessionId : void 0;
3704
+ const workspace = targetSessionId === void 0 && typeof args?.workspaceId === "string" && args.workspaceId !== "" ? { workspaceId: args.workspaceId } : void 0;
3705
+ try {
3706
+ const { sessionId, distilled, attached, workspaceId } = await transferToHarnessSession(hostCtx, {
3707
+ transcript: chat,
3708
+ cwd: args?.cwd,
3709
+ workspace,
3710
+ targetSessionId
3711
+ }, distill);
3712
+ return {
3713
+ sessionId,
3714
+ distilled,
3715
+ attached,
3716
+ continued: targetSessionId !== void 0,
3717
+ workspaceId
3718
+ };
3719
+ } catch (error) {
3720
+ return {
3721
+ sessionId: "",
3722
+ distilled: false,
3723
+ attached: false,
3724
+ continued: targetSessionId !== void 0,
3725
+ error: `webchat_transfer: 转移失败 — ${String(error)}`
3726
+ };
3727
+ }
3728
+ }
3729
+ });
3730
+ }
3731
+ //#endregion
3732
+ //#region src/index.ts
3733
+ /** Stable cordis plugin name. */
3734
+ const name = "webchat";
3735
+ /** Services required before the web-chat surfaces can mount. */
3736
+ const inject = [
3737
+ "webServer",
3738
+ "tools",
3739
+ "systemPrompt",
3740
+ "sessions"
3741
+ ];
3742
+ /**
3743
+ * Settings namespace of the web-chat capability — the section the web
3744
+ * settings surface edits. Spelled here rather than imported: the browser half
3745
+ * spells the same value and must not depend on a Host package.
3746
+ */
3747
+ const WEBCHAT_SETTINGS_NAMESPACE = "dsh-webchat";
3748
+ const Config = import_lib.default.object({
3749
+ announceToAgent: import_lib.default.boolean().default(true),
3750
+ enabled: import_lib.default.boolean().default(true),
3751
+ browserChannel: import_lib.default.string().default("auto"),
3752
+ browserExecutablePath: import_lib.default.string().default(""),
3753
+ browserProxy: import_lib.default.string().default("direct"),
3754
+ browserHeadless: import_lib.default.boolean().default(true),
3755
+ replyTimeoutMs: import_lib.default.number().default(18e4),
3756
+ dataDir: import_lib.default.string().default(""),
3757
+ transferDistill: import_lib.default.boolean().default(true),
3758
+ transferProvider: import_lib.default.string().default(""),
3759
+ transferModel: import_lib.default.string().default(""),
3760
+ transferMaxTokens: import_lib.default.number().default(4096),
3761
+ transferChunkTokens: import_lib.default.number().default(1024)
3762
+ });
3763
+ /** Schema default, re-read for hand-built test contexts (the loader applies them normally). */
3764
+ const DEFAULT_ANNOUNCE = true;
3765
+ /** Order of the announcement section within the tool-guidance band. */
3766
+ const SECTION_ORDER = 155;
3767
+ /** Model-facing announcement: plugin presence, capabilities, and limits. */
3768
+ const WEBCHAT_GUIDANCE = "本机已安装 dsh-webchat 插件(Codex ChatGPT 模式 · DeepSeek 网页端聊天):侧边栏「网页聊天」入口;通过真实浏览器驱动 chat.deepseek.com(DeepSeek 网页模型,深度思考/智能搜索开关,网页登录会话,无需 API 额度)。能力:webchat_status 查看登录/会话状态、webchat_send 通过网页端发送消息并流式获取回复(可附带本地图片路径做多模态提问)、webchat_recover 把网页端已有会话同步/恢复到本地、webchat_import 把存储的网页对话导入为 markdown 上下文、webchat_transfer 把网页对话蒸馏成可执行任务简报并创建新 harness 会话(首条消息即任务简报,而非原始聊天记录),或经 targetSessionId 把简报作为新消息追加到已有会话延续同一任务;GUI 面板可将对话随时导出为工作区文件或转入 harness 会话。限制:首次使用需用户在弹出的浏览器窗口完成 DeepSeek 网页登录;网页端受 DeepSeek 官方风控,操作失败或页面改版时返回错误而非崩溃;面板提供「深度思考(R1)」与「智能搜索」开关(网页端无模型选择器)。用户提到「网页聊天 / 网页端 / ChatGPT 模式 / deepseek web / 转移到 harness」时即指本插件,请据此协作。";
3769
+ /** Convert resolved config to engine config. */
3770
+ function engineConfigOf(resolve) {
3771
+ const value = resolve();
3772
+ const dataDir = value.dataDir?.trim() ?? "";
3773
+ return {
3774
+ dataDir: dataDir !== "" ? dataDir : defaultDataDirOf(),
3775
+ channel: value.browserChannel === "auto" ? void 0 : value.browserChannel || void 0,
3776
+ executablePath: value.browserExecutablePath !== "" ? value.browserExecutablePath : void 0,
3777
+ proxy: value.browserProxy,
3778
+ headless: value.browserHeadless,
3779
+ replyTimeoutMs: value.replyTimeoutMs
3780
+ };
3781
+ }
3782
+ /** Default plugin data dir (mirrors store.defaultDataDir). */
3783
+ function defaultDataDirOf() {
3784
+ return `${process.env.DSH_HOME ?? process.env.HOME ?? "."}/.dsh/dsh-webchat`;
3785
+ }
3786
+ /**
3787
+ * Mount the engine, routes, tools, and announcement.
3788
+ * @param ctx - host plugin context carrying webServer/tools/systemPrompt.
3789
+ * @param config - resolved plugin config (schema defaults applied by the loader).
3790
+ */
3791
+ function apply(ctx, config) {
3792
+ let current = () => config ?? {};
3793
+ const resolve = () => ({
3794
+ announceToAgent: current().announceToAgent ?? DEFAULT_ANNOUNCE,
3795
+ enabled: current().enabled ?? true,
3796
+ browserChannel: current().browserChannel ?? "auto",
3797
+ browserExecutablePath: current().browserExecutablePath ?? "",
3798
+ browserProxy: current().browserProxy ?? "direct",
3799
+ browserHeadless: current().browserHeadless ?? true,
3800
+ replyTimeoutMs: current().replyTimeoutMs ?? 18e4,
3801
+ dataDir: current().dataDir ?? "",
3802
+ transferDistill: current().transferDistill ?? true,
3803
+ transferProvider: current().transferProvider ?? "",
3804
+ transferModel: current().transferModel ?? "",
3805
+ transferMaxTokens: current().transferMaxTokens ?? 4096,
3806
+ transferChunkTokens: current().transferChunkTokens ?? 1024
3807
+ });
3808
+ const distillConfigOf = (value) => ({
3809
+ distill: value.transferDistill ?? true,
3810
+ provider: (value.transferProvider ?? "").trim(),
3811
+ model: (value.transferModel ?? "").trim(),
3812
+ maxTokens: value.transferMaxTokens ?? 4096,
3813
+ chunkTokens: value.transferChunkTokens ?? 1024
3814
+ });
3815
+ const store = new TranscriptStore({ dataDir: resolve().dataDir !== "" ? resolve().dataDir : void 0 });
3816
+ const engine = new DeepSeekWebEngine(store, engineConfigOf(resolve));
3817
+ ctx.effect(() => () => {
3818
+ engine.disposeBrowser();
3819
+ }, "dsh-webchat: engine");
3820
+ const routes = makeRoutes({
3821
+ ctx,
3822
+ engine,
3823
+ store,
3824
+ distill: distillConfigOf(resolve())
3825
+ });
3826
+ const listWorkspaces = () => {
3827
+ const registry = ctx.get("workspaceRegistry");
3828
+ if (registry === void 0) return void 0;
3829
+ return registry.list().map((ws) => ({
3830
+ id: ws.id,
3831
+ path: ws.path,
3832
+ title: ws.title
3833
+ }));
3834
+ };
3835
+ const tools = [
3836
+ webChatStatusTool(engine, store, listWorkspaces),
3837
+ webChatSendTool(engine),
3838
+ webChatRecoverTool(engine),
3839
+ webChatImportTool(store),
3840
+ webChatTransferTool(ctx, store, distillConfigOf(resolve()))
3841
+ ];
3842
+ let disposeSection;
3843
+ let disposeRoutes;
3844
+ let disposeTools;
3845
+ const sync = () => {
3846
+ if (disposeSection !== void 0) {
3847
+ disposeSection();
3848
+ disposeSection = void 0;
3849
+ }
3850
+ if (disposeRoutes !== void 0) {
3851
+ disposeRoutes();
3852
+ disposeRoutes = void 0;
3853
+ }
3854
+ if (disposeTools !== void 0) {
3855
+ disposeTools();
3856
+ disposeTools = void 0;
3857
+ }
3858
+ const value = resolve();
3859
+ if (!value.enabled) return;
3860
+ if (value.announceToAgent) disposeSection = ctx.systemPrompt.section({
3861
+ name: "plugin:dsh-webchat",
3862
+ order: SECTION_ORDER,
3863
+ text: WEBCHAT_GUIDANCE
3864
+ });
3865
+ disposeRoutes = ctx.effect(() => {
3866
+ const disposers = routes.map((route) => ctx.webServer.register(route));
3867
+ return () => {
3868
+ for (const dispose of disposers) dispose();
3869
+ };
3870
+ }, "dsh-webchat: routes");
3871
+ disposeTools = ctx.effect(() => {
3872
+ const disposers = tools.map((tool) => ctx.tools.register(tool));
3873
+ return () => {
3874
+ for (const dispose of disposers) dispose();
3875
+ };
3876
+ }, "dsh-webchat: tools");
3877
+ };
3878
+ // dsh >= 0.1.7 owns settings forms itself: one per profile entry, addressed by
3879
+ // the entry id, schema = this package's exported Config, and a form write
3880
+ // re-applies the entry (profile `patchReload: live`). The retired
3881
+ // installSettingsSection helper no longer exists in @deepseek-ai/dsh-settings
3882
+ // (0.2.0 exports only SettingsForms/SettingsConflictError/redactSecrets), so
3883
+ // the Loader-provided `config` below is the whole source of truth.
3884
+ sync();
3885
+ }
3886
+ //#endregion
3887
+ export { Config, WEBCHAT_GUIDANCE, WEBCHAT_SETTINGS_NAMESPACE, apply, inject, name };