@jaychang1989/dsh-webchat 0.3.0 → 0.4.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.
- package/NOTICE +10 -3
- package/README.md +44 -40
- package/README.zh.md +43 -44
- package/cordis.patch.yml +8 -7
- package/lib/client.js +402 -1693
- package/lib/index.js +246 -3884
- package/package.json +3 -35
- package/lib/client.js.map +0 -1
- package/lib/types/client/api.d.ts +0 -74
- package/lib/types/client/controller.d.ts +0 -19
- package/lib/types/client/index.d.ts +0 -30
- package/lib/types/client/locales.d.ts +0 -169
- package/lib/types/client/mount.d.ts +0 -23
- package/lib/types/client/panel/Markdown.d.ts +0 -15
- package/lib/types/client/panel/WebChatPanel.d.ts +0 -28
- package/lib/types/client/sidebar-entry-core.d.ts +0 -60
- package/lib/types/client/sidebar-entry.d.ts +0 -22
- package/lib/types/engine/engine.d.ts +0 -247
- package/lib/types/engine/html-md.d.ts +0 -23
- package/lib/types/index.d.ts +0 -63
- package/lib/types/protocol.d.ts +0 -107
- package/lib/types/routes.d.ts +0 -22
- package/lib/types/store.d.ts +0 -69
- package/lib/types/tools.d.ts +0 -27
- package/lib/types/transfer.d.ts +0 -138
- package/src/client/api.ts +0 -90
- package/src/client/controller.ts +0 -43
- package/src/client/css-modules.d.ts +0 -5
- package/src/client/index.ts +0 -75
- package/src/client/locales.ts +0 -172
- package/src/client/mount.tsx +0 -125
- package/src/client/panel/Markdown.tsx +0 -251
- package/src/client/panel/WebChatPanel.tsx +0 -760
- package/src/client/panel/panel.module.css +0 -936
- package/src/client/sidebar-entry-core.ts +0 -207
- package/src/client/sidebar-entry.ts +0 -49
- package/src/engine/engine.ts +0 -1385
- package/src/engine/html-md.ts +0 -126
- package/src/index.ts +0 -212
- package/src/protocol.ts +0 -116
- package/src/routes.ts +0 -307
- package/src/store.ts +0 -217
- package/src/tools.ts +0 -257
- package/src/transfer.ts +0 -602
package/lib/index.js
CHANGED
|
@@ -1,3887 +1,249 @@
|
|
|
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
1
|
/**
|
|
1026
|
-
*
|
|
1027
|
-
*
|
|
1028
|
-
*
|
|
1029
|
-
*
|
|
1030
|
-
*
|
|
1031
|
-
*
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
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 ? "" : ``;
|
|
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
|
|
2
|
+
* dsh-webchat — minimal launcher half.
|
|
3
|
+
*
|
|
4
|
+
* The DeepSeek web app IS the client: chat.deepseek.com already ships the model
|
|
5
|
+
* picker, deep think, smart search, history and attachments. This plugin does
|
|
6
|
+
* not reimplement any of that any more — it opens that page in a window and
|
|
7
|
+
* gets out of the way. Everything the previous release did around it (a custom
|
|
8
|
+
* chat panel, transcript storage, /api/dsh-webchat engine routes, the
|
|
9
|
+
* webchat_status/send/recover/import/transfer tools, transfer distillation)
|
|
10
|
+
* has been removed.
|
|
11
|
+
*
|
|
12
|
+
* Why a window and not a pane: chat.deepseek.com sends
|
|
13
|
+
* `Content-Security-Policy: frame-ancestors 'none'`, so it cannot be framed,
|
|
14
|
+
* and the desktop build runs with `webviewTag: false`, so it cannot be a
|
|
15
|
+
* `<webview>` either. A real window is the only faithful way to show the
|
|
16
|
+
* official page.
|
|
17
|
+
*
|
|
18
|
+
* Three strategies are tried in order, so the button always does something:
|
|
19
|
+
* 1. `app-window` — a BrowserWindow created by this process (the DSH
|
|
20
|
+
* desktop app is Electron). Focused instead of
|
|
21
|
+
* duplicated when one is already open.
|
|
22
|
+
* 2. `app-window-shell` — a chromeless Edge/Chrome window (`--app=`) with its
|
|
23
|
+
* own user-data-dir, for hosts where (1) is denied.
|
|
24
|
+
* 3. `system-browser` — the OS default browser.
|
|
25
|
+
*
|
|
26
|
+
* The browser half (./client) renders the single button that calls this.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { spawn } from 'node:child_process'
|
|
30
|
+
import { existsSync } from 'node:fs'
|
|
31
|
+
import { join } from 'node:path'
|
|
32
|
+
|
|
3733
33
|
/** Stable cordis plugin name. */
|
|
3734
|
-
const name =
|
|
3735
|
-
|
|
3736
|
-
|
|
3737
|
-
|
|
3738
|
-
|
|
3739
|
-
|
|
3740
|
-
|
|
3741
|
-
|
|
3742
|
-
/**
|
|
3743
|
-
|
|
3744
|
-
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3753
|
-
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
/**
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
|
|
3785
|
-
|
|
3786
|
-
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
|
|
3832
|
-
|
|
3833
|
-
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
|
|
3839
|
-
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
3857
|
-
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
3878
|
-
|
|
3879
|
-
|
|
3880
|
-
|
|
3881
|
-
|
|
3882
|
-
|
|
3883
|
-
|
|
3884
|
-
|
|
34
|
+
export const name = 'webchat'
|
|
35
|
+
|
|
36
|
+
/** The web server is the only service this launcher needs. */
|
|
37
|
+
export const inject = ['webServer']
|
|
38
|
+
|
|
39
|
+
/** The page this plugin exists to open. */
|
|
40
|
+
export const PAGE_URL = 'https://chat.deepseek.com/'
|
|
41
|
+
|
|
42
|
+
/** Route family; the browser half spells the same two paths. */
|
|
43
|
+
export const ROUTES = {
|
|
44
|
+
state: '/api/dsh-webchat/state',
|
|
45
|
+
open: '/api/dsh-webchat/open',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The app-owned window handle lives on a process-wide slot rather than in
|
|
50
|
+
* module state: a live profile reload would otherwise hand the next module
|
|
51
|
+
* instance a fresh `null` and orphan the open window, so every re-open would
|
|
52
|
+
* stack another one.
|
|
53
|
+
*/
|
|
54
|
+
const WINDOW_SLOT = Symbol.for('@jaychang1989/dsh-webchat.window')
|
|
55
|
+
|
|
56
|
+
/** The mutable window slot, created on first use. */
|
|
57
|
+
function windowSlot() {
|
|
58
|
+
const existing = globalThis[WINDOW_SLOT]
|
|
59
|
+
if (existing !== undefined) return existing
|
|
60
|
+
const created = { current: null }
|
|
61
|
+
globalThis[WINDOW_SLOT] = created
|
|
62
|
+
return created
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Last open attempt, reported to the panel. */
|
|
66
|
+
let lastAttempt = { ok: false, via: 'none', error: '', at: 0 }
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Remember one attempt.
|
|
70
|
+
* @param ok - whether the page was opened.
|
|
71
|
+
* @param via - the strategy that ran, or the last one that failed.
|
|
72
|
+
* @param error - failure detail, when there is one.
|
|
73
|
+
* @returns the recorded attempt.
|
|
74
|
+
*/
|
|
75
|
+
function record(ok, via, error = '') {
|
|
76
|
+
lastAttempt = { ok, via, error, at: Date.now() }
|
|
77
|
+
return lastAttempt
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Load Electron lazily. A plugin that cannot reach it must still load, and the
|
|
82
|
+
* import stays inside the call so a failed probe falls through to the next
|
|
83
|
+
* strategy instead of killing the entry at import time.
|
|
84
|
+
* @returns the Electron module.
|
|
85
|
+
* @throws when this process has no BrowserWindow to offer.
|
|
86
|
+
*/
|
|
87
|
+
async function electronApi() {
|
|
88
|
+
const electron = await import('electron')
|
|
89
|
+
if (electron === null || typeof electron !== 'object' || electron.BrowserWindow === undefined) {
|
|
90
|
+
throw new Error('electron BrowserWindow is unavailable in this process')
|
|
91
|
+
}
|
|
92
|
+
return electron
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Windows chromeless-window executables, in preference order. */
|
|
96
|
+
const SHELL_CANDIDATES = [
|
|
97
|
+
['ProgramFiles', 'Microsoft/Edge/Application/msedge.exe'],
|
|
98
|
+
['ProgramFiles(x86)', 'Microsoft/Edge/Application/msedge.exe'],
|
|
99
|
+
['LOCALAPPDATA', 'Microsoft/Edge/Application/msedge.exe'],
|
|
100
|
+
['ProgramFiles', 'Google/Chrome/Application/chrome.exe'],
|
|
101
|
+
['ProgramFiles(x86)', 'Google/Chrome/Application/chrome.exe'],
|
|
102
|
+
['LOCALAPPDATA', 'Google/Chrome/Application/chrome.exe'],
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Locate an Edge/Chrome executable for the chromeless fallback.
|
|
107
|
+
* @returns the executable path, or undefined when neither is installed.
|
|
108
|
+
*/
|
|
109
|
+
export function shellExecutable() {
|
|
110
|
+
const fixed = process.platform === 'darwin'
|
|
111
|
+
? ['/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome']
|
|
112
|
+
: process.platform === 'linux'
|
|
113
|
+
? ['/usr/bin/microsoft-edge', '/usr/bin/google-chrome', '/usr/bin/chromium']
|
|
114
|
+
: []
|
|
115
|
+
for (const candidate of fixed) if (existsSync(candidate)) return candidate
|
|
116
|
+
for (const [env, relative] of SHELL_CANDIDATES) {
|
|
117
|
+
const root = process.env[env]
|
|
118
|
+
if (root === undefined || root === '') continue
|
|
119
|
+
const candidate = join(root, ...relative.split('/'))
|
|
120
|
+
if (existsSync(candidate)) return candidate
|
|
121
|
+
}
|
|
122
|
+
return undefined
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Directory holding the chromeless window's own profile (so login persists). */
|
|
126
|
+
function shellProfileDir() {
|
|
127
|
+
const home = process.env.DSH_HOME ?? process.env.USERPROFILE ?? process.env.HOME ?? '.'
|
|
128
|
+
return join(home, '.dsh', 'dsh-webchat', 'app-window')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Open — or focus — a window owned by this process. */
|
|
132
|
+
async function openAppWindow() {
|
|
133
|
+
const { BrowserWindow } = await electronApi()
|
|
134
|
+
const slot = windowSlot()
|
|
135
|
+
if (slot.current !== null && slot.current.isDestroyed() === false) {
|
|
136
|
+
slot.current.focus()
|
|
137
|
+
return 'app-window (focused)'
|
|
138
|
+
}
|
|
139
|
+
const window = new BrowserWindow({
|
|
140
|
+
width: 1200,
|
|
141
|
+
height: 860,
|
|
142
|
+
autoHideMenuBar: true,
|
|
143
|
+
title: 'DeepSeek',
|
|
144
|
+
backgroundColor: '#ffffff',
|
|
145
|
+
webPreferences: { nodeIntegration: false, contextIsolation: true, sandbox: true, webviewTag: false },
|
|
146
|
+
})
|
|
147
|
+
slot.current = window
|
|
148
|
+
window.on('closed', () => {
|
|
149
|
+
if (slot.current === window) slot.current = null
|
|
150
|
+
})
|
|
151
|
+
await window.loadURL(PAGE_URL)
|
|
152
|
+
window.focus()
|
|
153
|
+
return 'app-window'
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Open a chromeless Edge/Chrome window with its own profile. */
|
|
157
|
+
async function openShellWindow() {
|
|
158
|
+
const executable = shellExecutable()
|
|
159
|
+
if (executable === undefined) throw new Error('no Edge or Chrome installation found')
|
|
160
|
+
const child = spawn(executable, [
|
|
161
|
+
`--app=${PAGE_URL}`,
|
|
162
|
+
`--user-data-dir=${shellProfileDir()}`,
|
|
163
|
+
'--no-first-run',
|
|
164
|
+
'--no-default-browser-check',
|
|
165
|
+
], { detached: true, stdio: 'ignore' })
|
|
166
|
+
child.unref()
|
|
167
|
+
return 'app-window-shell'
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Hand the page to the OS default browser. */
|
|
171
|
+
async function openSystemBrowser() {
|
|
172
|
+
const [command, args] = process.platform === 'win32'
|
|
173
|
+
? ['cmd', ['/c', 'start', '', PAGE_URL]]
|
|
174
|
+
: process.platform === 'darwin'
|
|
175
|
+
? ['open', [PAGE_URL]]
|
|
176
|
+
: ['xdg-open', [PAGE_URL]]
|
|
177
|
+
const child = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: true })
|
|
178
|
+
child.unref()
|
|
179
|
+
return 'system-browser'
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Strategies tried by {@link openPage}, in order. */
|
|
183
|
+
export const OPEN_STRATEGIES = [
|
|
184
|
+
{ via: 'app-window', run: openAppWindow },
|
|
185
|
+
{ via: 'app-window-shell', run: openShellWindow },
|
|
186
|
+
{ via: 'system-browser', run: openSystemBrowser },
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Open the page with the first strategy that works.
|
|
191
|
+
* @param strategies - strategies to try; defaults to {@link OPEN_STRATEGIES}.
|
|
192
|
+
* @returns the recorded attempt: ok, the winning `via`, or the last error.
|
|
193
|
+
*/
|
|
194
|
+
export async function openPage(strategies = OPEN_STRATEGIES) {
|
|
195
|
+
let failure = { via: 'none', error: 'no strategy was tried' }
|
|
196
|
+
for (const strategy of strategies) {
|
|
197
|
+
try {
|
|
198
|
+
const detail = await strategy.run()
|
|
199
|
+
return record(true, typeof detail === 'string' && detail !== '' ? detail : strategy.via)
|
|
200
|
+
} catch (error) {
|
|
201
|
+
failure = { via: strategy.via, error: error instanceof Error ? error.message : String(error) }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return record(false, failure.via, failure.error)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Register the launcher's two routes.
|
|
209
|
+
* @param ctx - host context carrying the web server.
|
|
210
|
+
*/
|
|
211
|
+
export function apply(ctx) {
|
|
212
|
+
const json = (res, status, payload) => {
|
|
213
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
214
|
+
res.end(JSON.stringify(payload))
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
ctx.effect(() => ctx.webServer.register({
|
|
218
|
+
kind: 'exact',
|
|
219
|
+
path: ROUTES.state,
|
|
220
|
+
handler: (req, res) => {
|
|
221
|
+
if (req.method !== 'GET') {
|
|
222
|
+
res.writeHead(405, { allow: 'GET' })
|
|
223
|
+
res.end()
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
const slot = windowSlot()
|
|
227
|
+
json(res, 200, {
|
|
228
|
+
ok: true,
|
|
229
|
+
url: PAGE_URL,
|
|
230
|
+
appWindowOpen: slot.current !== null && slot.current.isDestroyed() === false,
|
|
231
|
+
last: lastAttempt,
|
|
232
|
+
})
|
|
233
|
+
},
|
|
234
|
+
}), 'dsh-webchat: state route')
|
|
235
|
+
|
|
236
|
+
ctx.effect(() => ctx.webServer.register({
|
|
237
|
+
kind: 'exact',
|
|
238
|
+
path: ROUTES.open,
|
|
239
|
+
handler: async (req, res) => {
|
|
240
|
+
if (req.method !== 'POST') {
|
|
241
|
+
res.writeHead(405, { allow: 'POST' })
|
|
242
|
+
res.end()
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
const result = await openPage()
|
|
246
|
+
json(res, result.ok ? 200 : 502, result)
|
|
247
|
+
},
|
|
248
|
+
}), 'dsh-webchat: open route')
|
|
3885
249
|
}
|
|
3886
|
-
//#endregion
|
|
3887
|
-
export { Config, WEBCHAT_GUIDANCE, WEBCHAT_SETTINGS_NAMESPACE, apply, inject, name };
|