@tianyulinfeng1/dsh-plugin-deepseek-chat 1.0.0 → 1.1.2
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/CHANGELOG.md +137 -0
- package/README.en.md +55 -24
- package/README.md +58 -26
- package/cordis.patch.yml +3 -3
- package/docs/RELEASE_NOTES_v1.1.1.md +64 -0
- package/docs/RELEASE_NOTES_v1.1.2.md +149 -0
- package/docs/login-persistence.md +187 -0
- package/docs/screenshot.png +0 -0
- package/lib/client.js +464 -65
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/package.json +9 -3
- package/scripts/patch-dsh-persistent-partition.cjs +174 -0
- package/scripts/patch-dsh-persistent-partition.mjs +16 -0
package/lib/client.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Browser half of dsh-plugin-deepseek-chat.
|
|
2
|
+
* Browser half of dsh-plugin-deepseek-chat (v1.1.1).
|
|
3
3
|
*
|
|
4
4
|
* Registers one global left-sidebar panel — the same mechanism the Plugins and
|
|
5
5
|
* Scheduled tasks entries use:
|
|
@@ -12,22 +12,48 @@
|
|
|
12
12
|
* lease bridge the built-in sidebar browser uses: acquire a lease → create a
|
|
13
13
|
* webview named by the lease with its approved partition → load the URL.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* panel with a Retry button; it never escapes to the shell.
|
|
15
|
+
* Stable login session (v1.1.1)
|
|
16
|
+
* -----------------------------
|
|
17
|
+
* The desktop shell caches ONE process-lifetime session per `workspace` string
|
|
18
|
+
* passed to acquire(), and release() destroys only the guest, never the
|
|
19
|
+
* partition's cookies. So the workspace MUST be an absolute constant:
|
|
20
|
+
* `panel:deepseek-chat`. v1.1.0 keyed it to an async account probe, which
|
|
21
|
+
* transiently fails during reconnects — every change handed the panel a fresh
|
|
22
|
+
* cookie jar and forced another QR scan. That regression is fixed here.
|
|
24
23
|
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
24
|
+
* Account awareness (display only)
|
|
25
|
+
* --------------------------------
|
|
26
|
+
* DSH's own sign-in is a platform.deepseek.com OAuth grant; the chat site uses
|
|
27
|
+
* a different, site-owned Token. There is no official exchange, and the
|
|
28
|
+
* renderer bridge exposes no token/cookie API, so a pure plugin cannot reuse
|
|
29
|
+
* the DSH token for the web chat (see docs/login-persistence.md §6). All this
|
|
30
|
+
* file does with the account is DISPLAY it:
|
|
31
|
+
*
|
|
32
|
+
* - a soft probe reads the Client `remote.account` namespace
|
|
33
|
+
* (`getState`/`getProfile`, generated `{ ok, value }` envelopes) and shows
|
|
34
|
+
* the state/name in a small strip;
|
|
35
|
+
* - the account state NEVER feeds the lease identity, the storage key, an
|
|
36
|
+
* epoch, or the guest lifecycle — no rebuild, no partition change, ever;
|
|
37
|
+
* - a transient `unknown` is ignored; only a confirmed `signed-out` updates
|
|
38
|
+
* the strip; a missing/late Remote never blocks the guest from starting.
|
|
39
|
+
*
|
|
40
|
+
* Failure containment is still the hard rule of this file. DSH treats any
|
|
41
|
+
* uncaught error thrown by a plugin as a crash of its slot entry, and a crash
|
|
42
|
+
* inside the `root` slot unmounts the entire application UI. Electron throws
|
|
43
|
+
* from webview methods such as `getURL()` / `loadURL()` as soon as the element
|
|
44
|
+
* leaves the document, and React runs effect cleanups after the DOM removal —
|
|
45
|
+
* so every guest call is attached-checked and wrapped, every event handler
|
|
46
|
+
* catches, a React error boundary keeps a render failure inside this panel, and
|
|
47
|
+
* a failure removes the dead guest before showing the in-panel Retry card.
|
|
28
48
|
*/
|
|
29
49
|
window.__ModuleLoader__.load({
|
|
30
|
-
|
|
50
|
+
// MUST equal package.json "name" (the scoped npm name). The Host keys every
|
|
51
|
+
// client graph row by the package name and, after executing a bundle script,
|
|
52
|
+
// checks `factories.has(<row id>)`. A mismatched id makes arrive() think the
|
|
53
|
+
// bundle registered nothing, re-execute the same script, and throw
|
|
54
|
+
// "duplicate factory registration ... (bundle executed twice without
|
|
55
|
+
// invalidate?)" during web boot. See docs/RELEASE_NOTES_v1.1.1.md.
|
|
56
|
+
id: "@tianyulinfeng1/dsh-plugin-deepseek-chat",
|
|
31
57
|
factory: (require) => {
|
|
32
58
|
var module = { exports: {} };
|
|
33
59
|
var exports = module.exports;
|
|
@@ -35,13 +61,24 @@ window.__ModuleLoader__.load({
|
|
|
35
61
|
const react = require("react");
|
|
36
62
|
|
|
37
63
|
const PANEL_ID = "deepseek-chat";
|
|
64
|
+
/**
|
|
65
|
+
* DSH runtime version sent as `x-client-version` on Platform account
|
|
66
|
+
* reads. The boot manifest carries the exact connected release; the
|
|
67
|
+
* minimum supported release is the fallback for a page without it.
|
|
68
|
+
*/
|
|
69
|
+
const HOST_VERSION_FALLBACK = "0.2.2-rc.1";
|
|
38
70
|
const CHAT_URL = "https://chat.deepseek.com/";
|
|
39
71
|
/**
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
72
|
+
* Constant storage identities for the guest partition and the
|
|
73
|
+
* last-conversation memory. The main process keeps one partition per
|
|
74
|
+
* `workspace` string for the process lifetime; both values MUST NOT
|
|
75
|
+
* depend on account state, or a transient probe failure would swap the
|
|
76
|
+
* cookie jar and force a fresh QR scan. v1.1.1 regression fix.
|
|
43
77
|
*/
|
|
44
|
-
const
|
|
78
|
+
const PARTITION_BASE = "panel:deepseek-chat";
|
|
79
|
+
const STORAGE_BASE = "dsh-plugin-deepseek-chat:last-url";
|
|
80
|
+
/** Paths that are not a conversation: a brand-new chat, or an auth page. */
|
|
81
|
+
const NON_CONVERSATION_PATHS = new Set(["/", "/sign_in", "/login", "/signup", "/auth"]);
|
|
45
82
|
|
|
46
83
|
const english = typeof navigator !== "undefined" && /^en\b/i.test(navigator.language ?? "");
|
|
47
84
|
const COPY = english ? {
|
|
@@ -51,7 +88,12 @@ window.__ModuleLoader__.load({
|
|
|
51
88
|
failed: "The chat page could not be opened",
|
|
52
89
|
crashed: "The embedded chat stopped responding",
|
|
53
90
|
timeout: "The embedded chat did not start",
|
|
54
|
-
retry: "Retry"
|
|
91
|
+
retry: "Retry",
|
|
92
|
+
probing: "Syncing the DSH account…",
|
|
93
|
+
synced: "Synced with the DSH account",
|
|
94
|
+
syncedAs: (name) => `Synced with the DSH account · ${name}`,
|
|
95
|
+
unsigned: "No DSH account signed in — the chat panel is not synced",
|
|
96
|
+
unknown: "Account state unavailable — conversation kept locally"
|
|
55
97
|
} : {
|
|
56
98
|
label: "DeepSeek 聊天",
|
|
57
99
|
loading: "正在加载 DeepSeek 聊天…",
|
|
@@ -59,13 +101,45 @@ window.__ModuleLoader__.load({
|
|
|
59
101
|
failed: "聊天页面打开失败",
|
|
60
102
|
crashed: "内嵌聊天进程已退出",
|
|
61
103
|
timeout: "内嵌聊天页面没有启动",
|
|
62
|
-
retry: "重试"
|
|
104
|
+
retry: "重试",
|
|
105
|
+
probing: "正在同步 DSH 登录账号…",
|
|
106
|
+
synced: "已与 DSH 登录账号同步",
|
|
107
|
+
syncedAs: (name) => `已与 DSH 登录账号同步 · ${name}`,
|
|
108
|
+
unsigned: "未登录 DSH 账号,聊天面板暂未同步",
|
|
109
|
+
unknown: "账号状态不可用,会话按本地模式保存"
|
|
63
110
|
};
|
|
64
111
|
|
|
65
|
-
/**
|
|
66
|
-
const
|
|
67
|
-
|
|
68
|
-
const
|
|
112
|
+
/** Normalized account states the panel can distinguish. */
|
|
113
|
+
const ACCOUNT_UNKNOWN = "unknown";
|
|
114
|
+
const ACCOUNT_SIGNED_IN = "signed-in";
|
|
115
|
+
const ACCOUNT_SIGNED_OUT = "signed-out";
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* ONE stable guest partition for the whole process.
|
|
119
|
+
*
|
|
120
|
+
* The desktop shell keys a process-lifetime partition Map by the
|
|
121
|
+
* `workspace` string passed to acquire() and reuses the same session for
|
|
122
|
+
* every acquire() with that key. release() destroys only the guest, never
|
|
123
|
+
* the partition's cookies. Consequently, changing this value — even once
|
|
124
|
+
* — hands the panel a fresh cookie jar and forces the user to scan the
|
|
125
|
+
* chat login QR code again. It MUST NOT depend on the DSH account probe:
|
|
126
|
+
* that probe is asynchronous, runs every 5 seconds, and can transiently
|
|
127
|
+
* return "unknown" during a reconnect or session switch.
|
|
128
|
+
* @returns the fixed workspace identity for guest leases.
|
|
129
|
+
*/
|
|
130
|
+
function partitionKeyFor() {
|
|
131
|
+
return PARTITION_BASE;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* ONE stable conversation-memory key. Account status changes must not
|
|
136
|
+
* move it either, or a transient probe failure would silently lose the
|
|
137
|
+
* remembered conversation.
|
|
138
|
+
* @returns the fixed localStorage key.
|
|
139
|
+
*/
|
|
140
|
+
function storageKeyFor() {
|
|
141
|
+
return STORAGE_BASE;
|
|
142
|
+
}
|
|
69
143
|
|
|
70
144
|
/**
|
|
71
145
|
* Parse a chat.deepseek.com page URL.
|
|
@@ -77,13 +151,33 @@ window.__ModuleLoader__.load({
|
|
|
77
151
|
try {
|
|
78
152
|
const url = new URL(value);
|
|
79
153
|
if (url.protocol !== "https:" || url.hostname !== "chat.deepseek.com") return void 0;
|
|
80
|
-
if (url.username !== "" || url.password !== "") return void 0;
|
|
154
|
+
if (url.username !== "" || url.password !== "" || url.port !== "") return void 0;
|
|
81
155
|
return url;
|
|
82
156
|
} catch {
|
|
83
157
|
return void 0;
|
|
84
158
|
}
|
|
85
159
|
}
|
|
86
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Destinations a guest popup may open. The chat site and the Platform
|
|
163
|
+
* login pages are the only hosts this panel deliberately navigates to;
|
|
164
|
+
* an arbitrary URL from the open-request bridge is never loaded.
|
|
165
|
+
* @param value - candidate URL.
|
|
166
|
+
* @returns the normalized URL, or undefined.
|
|
167
|
+
*/
|
|
168
|
+
function parsePopupUrl(value) {
|
|
169
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 4096) return void 0;
|
|
170
|
+
try {
|
|
171
|
+
const url = new URL(value);
|
|
172
|
+
if (url.protocol !== "https:") return void 0;
|
|
173
|
+
if (url.username !== "" || url.password !== "" || url.port !== "") return void 0;
|
|
174
|
+
if (url.hostname !== "chat.deepseek.com" && url.hostname !== "platform.deepseek.com") return void 0;
|
|
175
|
+
return url.toString();
|
|
176
|
+
} catch {
|
|
177
|
+
return void 0;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
87
181
|
/**
|
|
88
182
|
* Whether a URL deep enough to be a conversation page.
|
|
89
183
|
* @param url - parsed chat URL, possibly undefined.
|
|
@@ -96,12 +190,13 @@ window.__ModuleLoader__.load({
|
|
|
96
190
|
}
|
|
97
191
|
|
|
98
192
|
/**
|
|
99
|
-
* Read the conversation remembered from the last visit.
|
|
193
|
+
* Read the conversation remembered from the last visit for one account.
|
|
194
|
+
* @param storageKey - account-scoped storage key.
|
|
100
195
|
* @returns the URL to open; the site root when nothing usable is remembered.
|
|
101
196
|
*/
|
|
102
|
-
function rememberedTarget() {
|
|
197
|
+
function rememberedTarget(storageKey) {
|
|
103
198
|
try {
|
|
104
|
-
const saved = parseChatUrl(window.localStorage.getItem(
|
|
199
|
+
const saved = parseChatUrl(window.localStorage.getItem(storageKey));
|
|
105
200
|
return isConversation(saved) ? saved.toString() : CHAT_URL;
|
|
106
201
|
} catch {
|
|
107
202
|
return CHAT_URL;
|
|
@@ -114,19 +209,230 @@ window.__ModuleLoader__.load({
|
|
|
114
209
|
* A brand-new chat clears the memory so the next visit starts clean. An auth
|
|
115
210
|
* page never overwrites it, so a login detour does not lose the conversation.
|
|
116
211
|
* @param raw - current page URL reported by the guest.
|
|
212
|
+
* @param storageKey - account-scoped storage key.
|
|
117
213
|
*/
|
|
118
|
-
function rememberTarget(raw) {
|
|
214
|
+
function rememberTarget(raw, storageKey) {
|
|
119
215
|
const url = parseChatUrl(raw);
|
|
120
216
|
if (url === void 0) return;
|
|
121
217
|
try {
|
|
122
218
|
const path = url.pathname.replace(/\/+$/u, "") || "/";
|
|
123
|
-
if (isConversation(url)) window.localStorage.setItem(
|
|
124
|
-
else if (path === "/") window.localStorage.removeItem(
|
|
219
|
+
if (isConversation(url)) window.localStorage.setItem(storageKey, url.toString());
|
|
220
|
+
else if (path === "/") window.localStorage.removeItem(storageKey);
|
|
125
221
|
} catch {
|
|
126
222
|
/* storage unavailable: the panel still works, it just forgets */
|
|
127
223
|
}
|
|
128
224
|
}
|
|
129
225
|
|
|
226
|
+
// ── account sync store ────────────────────────────────────────────────
|
|
227
|
+
/**
|
|
228
|
+
* One module-level snapshot shared by every mounted panel. The account
|
|
229
|
+
* Remote is display/awareness only; it never feeds the lease identity or
|
|
230
|
+
* the guest lifecycle, so a flaky probe can never recreate the webview.
|
|
231
|
+
*/
|
|
232
|
+
let accountSnapshot = { status: ACCOUNT_UNKNOWN, label: "", probed: false };
|
|
233
|
+
const accountListeners = new Set();
|
|
234
|
+
|
|
235
|
+
function notifyAccount() {
|
|
236
|
+
for (const listener of [...accountListeners]) {
|
|
237
|
+
try {
|
|
238
|
+
listener(accountSnapshot);
|
|
239
|
+
} catch {
|
|
240
|
+
/* a dead panel must not break another one */
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Subscribe a mounted panel; returns its disposer. */
|
|
246
|
+
function subscribeAccount(listener) {
|
|
247
|
+
accountListeners.add(listener);
|
|
248
|
+
return () => {
|
|
249
|
+
accountListeners.delete(listener);
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Publish a probed account state. This ONLY updates the account strip:
|
|
255
|
+
* it never bumps an epoch and never rebuilds the guest. A transient
|
|
256
|
+
* "unknown" during reconnect is deliberately ignored so the displayed
|
|
257
|
+
* state does not flicker; a confirmed sign-out is shown immediately.
|
|
258
|
+
* @param status - normalized account state.
|
|
259
|
+
*/
|
|
260
|
+
function publishAccount(status) {
|
|
261
|
+
const previous = accountSnapshot;
|
|
262
|
+
// Ignore a transient unknown once we know a real state: it is usually
|
|
263
|
+
// a reconnect, not a sign-out, and must not disturb the panel.
|
|
264
|
+
const settled = status === ACCOUNT_UNKNOWN && previous.probed && previous.status !== ACCOUNT_UNKNOWN
|
|
265
|
+
? previous.status
|
|
266
|
+
: status;
|
|
267
|
+
accountSnapshot = {
|
|
268
|
+
status: settled,
|
|
269
|
+
// Preserve the name through a transient unknown; clear it only on a
|
|
270
|
+
// confirmed sign-out when the display explicitly publishes "".
|
|
271
|
+
label: settled === ACCOUNT_SIGNED_OUT ? "" : previous.label,
|
|
272
|
+
probed: true
|
|
273
|
+
};
|
|
274
|
+
notifyAccount();
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Publish a display name; a stale result from an older probe is dropped. */
|
|
278
|
+
function publishAccountLabel(label) {
|
|
279
|
+
if (accountSnapshot.label === label) return;
|
|
280
|
+
accountSnapshot = { ...accountSnapshot, label };
|
|
281
|
+
notifyAccount();
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Read one generated `ctx.remote.account` method. Generated Remotes answer
|
|
286
|
+
* with an `{ ok, value, error }` envelope, so a bare payload is never
|
|
287
|
+
* assumed; an absent namespace or a refused call degrades to undefined.
|
|
288
|
+
* @param ctx - client plugin context.
|
|
289
|
+
* @param method - generated account method name.
|
|
290
|
+
* @param args - method arguments.
|
|
291
|
+
* @returns the envelope's `value`, or undefined when unavailable.
|
|
292
|
+
*/
|
|
293
|
+
async function callAccount(ctx, method, args = []) {
|
|
294
|
+
let remote;
|
|
295
|
+
try {
|
|
296
|
+
remote = typeof ctx.get === "function" ? ctx.get("remote") : ctx.remote;
|
|
297
|
+
} catch {
|
|
298
|
+
remote = void 0;
|
|
299
|
+
}
|
|
300
|
+
const account = remote?.account;
|
|
301
|
+
const call = account?.[method];
|
|
302
|
+
if (typeof call !== "function") return void 0;
|
|
303
|
+
const result = await call.apply(account, args);
|
|
304
|
+
if (result === null || typeof result !== "object" || result.ok !== true) return void 0;
|
|
305
|
+
return result.value;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Platform client identity headers, read at call time as the UI does. */
|
|
309
|
+
function accountClient(ctx) {
|
|
310
|
+
return {
|
|
311
|
+
version: globalThis.__DSH_BOOT__?.release?.version || HOST_VERSION_FALLBACK,
|
|
312
|
+
locale: typeof navigator !== "undefined" ? navigator.language || "en-US" : "en-US",
|
|
313
|
+
timezoneOffsetSeconds: -new Date().getTimezoneOffset() * 60
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Read the live DSH account state and display name. This is awareness
|
|
319
|
+
* ONLY: no value here may ever feed the guest lease or rebuild the
|
|
320
|
+
* webview, or a transient Remote failure would drop the chat session.
|
|
321
|
+
* @param ctx - client plugin context.
|
|
322
|
+
* @returns `{ status, label }`.
|
|
323
|
+
*/
|
|
324
|
+
async function probeAccount(ctx) {
|
|
325
|
+
let state;
|
|
326
|
+
try {
|
|
327
|
+
state = await callAccount(ctx, "getState");
|
|
328
|
+
} catch {
|
|
329
|
+
state = void 0;
|
|
330
|
+
}
|
|
331
|
+
if (state?.status !== "credential-stored") {
|
|
332
|
+
return {
|
|
333
|
+
status: state?.status === "signed-out" ? ACCOUNT_SIGNED_OUT : ACCOUNT_UNKNOWN,
|
|
334
|
+
label: ""
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
const probe = { status: ACCOUNT_SIGNED_IN, label: "" };
|
|
338
|
+
try {
|
|
339
|
+
const outcome = await callAccount(ctx, "getProfile", [accountClient(ctx)]);
|
|
340
|
+
const value = outcome?.status === "ready" ? outcome.value : void 0;
|
|
341
|
+
probe.label = [value?.name, value?.contact, value?.id]
|
|
342
|
+
.find((item) => typeof item === "string" && item.length > 0) ?? "";
|
|
343
|
+
} catch {
|
|
344
|
+
/* profile is display-only: failure keeps the bare state */
|
|
345
|
+
}
|
|
346
|
+
return probe;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* Start the account watcher once per plugin activation. A 5-second poll
|
|
351
|
+
* plus `connection/reset` gives self-heal without depending on the exact
|
|
352
|
+
* Client shape of the account `watch` stream, and every resource is
|
|
353
|
+
* released through the plugin's own ctx effect.
|
|
354
|
+
* @param ctx - client plugin context.
|
|
355
|
+
*/
|
|
356
|
+
let accountSyncStarted = false;
|
|
357
|
+
function startAccountSync(ctx) {
|
|
358
|
+
if (accountSyncStarted) return;
|
|
359
|
+
accountSyncStarted = true;
|
|
360
|
+
let stopped = false;
|
|
361
|
+
let timer;
|
|
362
|
+
|
|
363
|
+
const refresh = async () => {
|
|
364
|
+
if (stopped) return;
|
|
365
|
+
let probe = { status: ACCOUNT_UNKNOWN, label: "" };
|
|
366
|
+
try {
|
|
367
|
+
probe = await probeAccount(ctx);
|
|
368
|
+
} catch {
|
|
369
|
+
/* Remote unavailable: publishAccount ignores a transient unknown */
|
|
370
|
+
probe = { status: ACCOUNT_UNKNOWN, label: "" };
|
|
371
|
+
}
|
|
372
|
+
if (stopped) return;
|
|
373
|
+
publishAccount(probe.status);
|
|
374
|
+
if (probe.label !== "") publishAccountLabel(probe.label);
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
const schedule = () => {
|
|
378
|
+
if (stopped) return;
|
|
379
|
+
timer = setTimeout(() => {
|
|
380
|
+
void refresh().finally(schedule);
|
|
381
|
+
}, 5000);
|
|
382
|
+
// A long-lived page keeps the poll; a minimal context (or a Node
|
|
383
|
+
// self-check) must not be held open by its timer alone.
|
|
384
|
+
try {
|
|
385
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
386
|
+
} catch {
|
|
387
|
+
/* not a Node timer */
|
|
388
|
+
}
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
void refresh().finally(schedule);
|
|
392
|
+
|
|
393
|
+
let offReset;
|
|
394
|
+
try {
|
|
395
|
+
if (typeof ctx.on === "function") offReset = ctx.on("connection/reset", () => {
|
|
396
|
+
void refresh();
|
|
397
|
+
});
|
|
398
|
+
} catch {
|
|
399
|
+
/* optional: the poll still self-heals */
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
let hasDisposal = false;
|
|
403
|
+
try {
|
|
404
|
+
if (typeof ctx.effect === "function") {
|
|
405
|
+
ctx.effect(() => () => {
|
|
406
|
+
stopped = true;
|
|
407
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
408
|
+
try {
|
|
409
|
+
if (typeof offReset === "function") offReset();
|
|
410
|
+
} catch {
|
|
411
|
+
/* already detached */
|
|
412
|
+
}
|
|
413
|
+
accountListeners.clear();
|
|
414
|
+
accountSyncStarted = false;
|
|
415
|
+
}, "deepseek-chat: account sync");
|
|
416
|
+
hasDisposal = true;
|
|
417
|
+
}
|
|
418
|
+
} catch {
|
|
419
|
+
/* fall through to the no-disposal path */
|
|
420
|
+
}
|
|
421
|
+
if (!hasDisposal) {
|
|
422
|
+
stopped = true;
|
|
423
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
424
|
+
try {
|
|
425
|
+
if (typeof offReset === "function") offReset();
|
|
426
|
+
} catch {
|
|
427
|
+
/* already detached */
|
|
428
|
+
}
|
|
429
|
+
// Without a real ctx effect there is no teardown to stop the poll;
|
|
430
|
+
// keep the first probe but never leave a repeating timer behind.
|
|
431
|
+
accountSyncStarted = false;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ── guest helpers ─────────────────────────────────────────────────────
|
|
130
436
|
/**
|
|
131
437
|
* Whether the guest element is still in the document. Electron's webview
|
|
132
438
|
* methods throw once it is detached, so this gates every guest call.
|
|
@@ -141,13 +447,7 @@ window.__ModuleLoader__.load({
|
|
|
141
447
|
}
|
|
142
448
|
}
|
|
143
449
|
|
|
144
|
-
/**
|
|
145
|
-
* Guest URL, or undefined when the element cannot report one. This is the
|
|
146
|
-
* call that used to escape: React cleans up effects after removing the DOM,
|
|
147
|
-
* and Electron then throws from `getURL()`.
|
|
148
|
-
* @param element - webview element.
|
|
149
|
-
* @returns the URL string, or undefined.
|
|
150
|
-
*/
|
|
450
|
+
/** Guest URL, or undefined when the element cannot report one. */
|
|
151
451
|
function readUrl(element) {
|
|
152
452
|
if (!attached(element)) return void 0;
|
|
153
453
|
try {
|
|
@@ -160,8 +460,6 @@ window.__ModuleLoader__.load({
|
|
|
160
460
|
|
|
161
461
|
/**
|
|
162
462
|
* Navigate the guest without ever throwing.
|
|
163
|
-
* @param element - webview element.
|
|
164
|
-
* @param url - target URL.
|
|
165
463
|
* @returns false when the guest is gone, so the caller can report it.
|
|
166
464
|
*/
|
|
167
465
|
function navigate(element, url) {
|
|
@@ -178,8 +476,6 @@ window.__ModuleLoader__.load({
|
|
|
178
476
|
/**
|
|
179
477
|
* Panel-list glyph, drawn on currentColor so the sidebar's active state owns
|
|
180
478
|
* the ink. Matches the sidebar's 16/18px icon seat.
|
|
181
|
-
* @param props - size passed by the sidebar panel list.
|
|
182
|
-
* @returns the glyph element.
|
|
183
479
|
*/
|
|
184
480
|
function ChatGlyph({ size = 18 }) {
|
|
185
481
|
return react.createElement("svg", {
|
|
@@ -211,6 +507,24 @@ window.__ModuleLoader__.load({
|
|
|
211
507
|
background: "var(--dsw-alias-bg-base)",
|
|
212
508
|
color: "var(--dsw-alias-label-primary)"
|
|
213
509
|
},
|
|
510
|
+
accountBar: {
|
|
511
|
+
flex: "none",
|
|
512
|
+
display: "flex",
|
|
513
|
+
alignItems: "center",
|
|
514
|
+
gap: "6px",
|
|
515
|
+
minHeight: "24px",
|
|
516
|
+
padding: "3px 10px",
|
|
517
|
+
borderBottom: ".5px solid var(--dsw-alias-border-l3)",
|
|
518
|
+
color: "var(--dsw-alias-label-tertiary)",
|
|
519
|
+
font: "var(--dsw-font-xxxs-11)"
|
|
520
|
+
},
|
|
521
|
+
accountDot: {
|
|
522
|
+
width: "6px",
|
|
523
|
+
height: "6px",
|
|
524
|
+
borderRadius: "50%",
|
|
525
|
+
background: "currentColor",
|
|
526
|
+
flex: "none"
|
|
527
|
+
},
|
|
214
528
|
host: {
|
|
215
529
|
position: "relative",
|
|
216
530
|
flex: "1",
|
|
@@ -249,6 +563,17 @@ window.__ModuleLoader__.load({
|
|
|
249
563
|
}
|
|
250
564
|
};
|
|
251
565
|
|
|
566
|
+
/** Copy for the account strip; the dot's opacity encodes the state. */
|
|
567
|
+
function accountCopy(account) {
|
|
568
|
+
if (account.status === ACCOUNT_SIGNED_IN) return {
|
|
569
|
+
text: account.label ? COPY.syncedAs(account.label) : COPY.synced,
|
|
570
|
+
opacity: "1"
|
|
571
|
+
};
|
|
572
|
+
if (account.status === ACCOUNT_SIGNED_OUT) return { text: COPY.unsigned, opacity: ".45" };
|
|
573
|
+
if (!account.probed) return { text: COPY.probing, opacity: ".3" };
|
|
574
|
+
return { text: COPY.unknown, opacity: ".3" };
|
|
575
|
+
}
|
|
576
|
+
|
|
252
577
|
/**
|
|
253
578
|
* Keeps a rendering failure inside this panel. DSH has no boundary of its own
|
|
254
579
|
* around slot entries: an escaping error unmounts the root slot, which is the
|
|
@@ -283,24 +608,32 @@ window.__ModuleLoader__.load({
|
|
|
283
608
|
}
|
|
284
609
|
|
|
285
610
|
/**
|
|
286
|
-
* The global chat panel: a guest webview filling the
|
|
611
|
+
* The global chat panel: an account strip and a guest webview filling the
|
|
612
|
+
* main column.
|
|
287
613
|
*
|
|
288
614
|
* The guest is created only after the lease resolves, and it is mounted with
|
|
289
615
|
* `name`/`partition`/`src` already set, because the main process inspects
|
|
290
|
-
* exactly that bootstrap src when it approves the attachment. The
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
* element.
|
|
295
|
-
* @returns the panel element tree.
|
|
616
|
+
* exactly that bootstrap src when it approves the attachment. The identity
|
|
617
|
+
* and the conversation memory are both scoped to the current DSH account;
|
|
618
|
+
* an account transition bumps `epoch`, which tears down the old guest and
|
|
619
|
+
* re-acquires a lease for the new identity.
|
|
296
620
|
*/
|
|
297
621
|
function ChatPanel() {
|
|
298
622
|
const hostRef = react.useRef(null);
|
|
299
623
|
const lastUrlRef = react.useRef(void 0);
|
|
624
|
+
const [account, setAccount] = react.useState(accountSnapshot);
|
|
300
625
|
const [failure, setFailure] = react.useState(void 0);
|
|
301
626
|
const [loading, setLoading] = react.useState(true);
|
|
302
627
|
const [attempt, setAttempt] = react.useState(0);
|
|
303
628
|
|
|
629
|
+
react.useEffect(() => subscribeAccount((next) => {
|
|
630
|
+
try {
|
|
631
|
+
setAccount(next);
|
|
632
|
+
} catch {
|
|
633
|
+
/* the panel is gone */
|
|
634
|
+
}
|
|
635
|
+
}), []);
|
|
636
|
+
|
|
304
637
|
const retry = react.useCallback(() => {
|
|
305
638
|
try {
|
|
306
639
|
setFailure(void 0);
|
|
@@ -325,20 +658,41 @@ window.__ModuleLoader__.load({
|
|
|
325
658
|
return;
|
|
326
659
|
}
|
|
327
660
|
|
|
661
|
+
// Both identities are constants. The desktop shell caches one
|
|
662
|
+
// session per workspace string; keeping them fixed is what makes
|
|
663
|
+
// switching panels/sessions preserve the chat login.
|
|
664
|
+
const storageKey = storageKeyFor();
|
|
665
|
+
const partitionKey = partitionKeyFor();
|
|
328
666
|
let disposed = false;
|
|
667
|
+
let failed = false;
|
|
329
668
|
let unsubscribeOpen;
|
|
330
669
|
let lease;
|
|
331
670
|
let watchdog;
|
|
332
671
|
const element = document.createElement("webview");
|
|
333
|
-
const initialTarget = rememberedTarget();
|
|
672
|
+
const initialTarget = rememberedTarget(storageKey);
|
|
334
673
|
let restoring = initialTarget !== CHAT_URL;
|
|
335
674
|
|
|
336
675
|
Object.assign(element.style, styles.guest);
|
|
337
676
|
element.setAttribute("allowpopups", "");
|
|
338
677
|
|
|
339
|
-
/**
|
|
678
|
+
/**
|
|
679
|
+
* Report a guest failure inside the panel; never throw. The dead
|
|
680
|
+
* guest element is removed immediately so a Retry cannot stack a
|
|
681
|
+
* second webview, and later events are suppressed. The lease is
|
|
682
|
+
* still released by the effect cleanup, exactly once.
|
|
683
|
+
*/
|
|
340
684
|
const fail = (error) => {
|
|
341
|
-
if (disposed) return;
|
|
685
|
+
if (disposed || failed) return;
|
|
686
|
+
failed = true;
|
|
687
|
+
try {
|
|
688
|
+
element.remove();
|
|
689
|
+
} catch {
|
|
690
|
+
/* already detached */
|
|
691
|
+
}
|
|
692
|
+
if (watchdog !== void 0) {
|
|
693
|
+
clearTimeout(watchdog);
|
|
694
|
+
watchdog = void 0;
|
|
695
|
+
}
|
|
342
696
|
try {
|
|
343
697
|
setLoading(false);
|
|
344
698
|
setFailure(String((error && error.message) || error));
|
|
@@ -351,11 +705,11 @@ window.__ModuleLoader__.load({
|
|
|
351
705
|
const remember = (raw) => {
|
|
352
706
|
if (raw === void 0) return;
|
|
353
707
|
lastUrlRef.current = raw;
|
|
354
|
-
rememberTarget(raw);
|
|
708
|
+
rememberTarget(raw, storageKey);
|
|
355
709
|
};
|
|
356
710
|
|
|
357
711
|
const onDomReady = () => {
|
|
358
|
-
if (disposed) return;
|
|
712
|
+
if (disposed || failed) return;
|
|
359
713
|
if (watchdog !== void 0) {
|
|
360
714
|
clearTimeout(watchdog);
|
|
361
715
|
watchdog = void 0;
|
|
@@ -364,7 +718,7 @@ window.__ModuleLoader__.load({
|
|
|
364
718
|
else remember(initialTarget);
|
|
365
719
|
};
|
|
366
720
|
const onStopLoading = () => {
|
|
367
|
-
if (disposed) return;
|
|
721
|
+
if (disposed || failed) return;
|
|
368
722
|
const raw = readUrl(element);
|
|
369
723
|
if (raw === void 0) return;
|
|
370
724
|
remember(raw);
|
|
@@ -376,15 +730,15 @@ window.__ModuleLoader__.load({
|
|
|
376
730
|
}
|
|
377
731
|
};
|
|
378
732
|
const onNavigate = () => {
|
|
379
|
-
if (disposed) return;
|
|
733
|
+
if (disposed || failed) return;
|
|
380
734
|
remember(readUrl(element));
|
|
381
735
|
};
|
|
382
736
|
const onNavigateInPage = (event) => {
|
|
383
|
-
if (disposed || event?.isMainFrame !== true) return;
|
|
737
|
+
if (disposed || failed || event?.isMainFrame !== true) return;
|
|
384
738
|
onNavigate();
|
|
385
739
|
};
|
|
386
740
|
const onFailLoad = (event) => {
|
|
387
|
-
if (disposed || event?.isMainFrame !== true) return;
|
|
741
|
+
if (disposed || failed || event?.isMainFrame !== true) return;
|
|
388
742
|
// -3 is ERR_ABORTED: a superseded navigation, not a failure.
|
|
389
743
|
if (event.errorCode === -3) return;
|
|
390
744
|
if (restoring) {
|
|
@@ -398,7 +752,7 @@ window.__ModuleLoader__.load({
|
|
|
398
752
|
};
|
|
399
753
|
/** The guest process exited or the guest was torn down underneath us. */
|
|
400
754
|
const onGuestGone = () => {
|
|
401
|
-
if (disposed) return;
|
|
755
|
+
if (disposed || failed) return;
|
|
402
756
|
fail(new Error(COPY.crashed));
|
|
403
757
|
};
|
|
404
758
|
|
|
@@ -412,18 +766,22 @@ window.__ModuleLoader__.load({
|
|
|
412
766
|
["destroyed", onGuestGone]
|
|
413
767
|
];
|
|
414
768
|
|
|
769
|
+
// A listener that cannot be attached leaves a guest nobody can
|
|
770
|
+
// drive; abort the start instead of mounting a silent webview.
|
|
771
|
+
let listenersReady = false;
|
|
415
772
|
try {
|
|
416
773
|
for (const [name, handler] of listeners) {
|
|
417
774
|
element.addEventListener(name, handler, name === "dom-ready" ? { once: true } : void 0);
|
|
418
775
|
}
|
|
776
|
+
listenersReady = true;
|
|
419
777
|
} catch (error) {
|
|
420
778
|
fail(error);
|
|
421
779
|
}
|
|
422
780
|
|
|
423
781
|
const start = async () => {
|
|
424
782
|
try {
|
|
425
|
-
const reservation = await bridge.acquire(
|
|
426
|
-
if (disposed) {
|
|
783
|
+
const reservation = await bridge.acquire(partitionKey);
|
|
784
|
+
if (disposed || failed) {
|
|
427
785
|
try {
|
|
428
786
|
Promise.resolve(bridge.release(reservation.lease)).catch(() => {});
|
|
429
787
|
} catch {
|
|
@@ -436,8 +794,10 @@ window.__ModuleLoader__.load({
|
|
|
436
794
|
element.setAttribute("partition", reservation.partition);
|
|
437
795
|
element.setAttribute("src", `about:blank#${reservation.lease}`);
|
|
438
796
|
try {
|
|
439
|
-
unsubscribeOpen = bridge.onOpenRequested(reservation.lease, (
|
|
440
|
-
if (
|
|
797
|
+
unsubscribeOpen = bridge.onOpenRequested(reservation.lease, (raw) => {
|
|
798
|
+
if (disposed || failed || typeof raw !== "string") return;
|
|
799
|
+
const target = parsePopupUrl(raw);
|
|
800
|
+
if (target !== void 0) navigate(element, target);
|
|
441
801
|
});
|
|
442
802
|
} catch {
|
|
443
803
|
/* popup bridge unavailable: the guest still works */
|
|
@@ -445,14 +805,14 @@ window.__ModuleLoader__.load({
|
|
|
445
805
|
host.append(element);
|
|
446
806
|
watchdog = setTimeout(() => {
|
|
447
807
|
watchdog = void 0;
|
|
448
|
-
if (disposed) return;
|
|
808
|
+
if (disposed || failed) return;
|
|
449
809
|
if (readUrl(element) === void 0) fail(new Error(COPY.timeout));
|
|
450
810
|
}, 15000);
|
|
451
811
|
} catch (error) {
|
|
452
812
|
fail(error);
|
|
453
813
|
}
|
|
454
814
|
};
|
|
455
|
-
void start();
|
|
815
|
+
if (listenersReady) void start();
|
|
456
816
|
|
|
457
817
|
return () => {
|
|
458
818
|
disposed = true;
|
|
@@ -492,7 +852,12 @@ window.__ModuleLoader__.load({
|
|
|
492
852
|
};
|
|
493
853
|
}, [attempt]);
|
|
494
854
|
|
|
855
|
+
const copy = accountCopy(account);
|
|
856
|
+
|
|
495
857
|
return react.createElement("div", { style: styles.root },
|
|
858
|
+
react.createElement("div", { style: styles.accountBar },
|
|
859
|
+
react.createElement("span", { style: { ...styles.accountDot, opacity: copy.opacity }, "aria-hidden": "true" }),
|
|
860
|
+
react.createElement("span", null, copy.text)),
|
|
496
861
|
react.createElement("div", { style: styles.host, ref: hostRef },
|
|
497
862
|
failure === void 0 && loading
|
|
498
863
|
? react.createElement("div", { style: styles.notice }, COPY.loading)
|
|
@@ -521,7 +886,21 @@ window.__ModuleLoader__.load({
|
|
|
521
886
|
// The panel needs the desktop guest bridge; on the Web surface an iframe
|
|
522
887
|
// cannot carry the site, so the plugin stays inert there.
|
|
523
888
|
if (globalThis.dshDesktop?.browser === void 0) return;
|
|
889
|
+
} catch {
|
|
890
|
+
return;
|
|
891
|
+
}
|
|
524
892
|
|
|
893
|
+
try {
|
|
894
|
+
startAccountSync(ctx);
|
|
895
|
+
} catch (error) {
|
|
896
|
+
try {
|
|
897
|
+
console.error("dsh-plugin-deepseek-chat: account sync failed", error);
|
|
898
|
+
} catch {
|
|
899
|
+
/* console unavailable */
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
try {
|
|
525
904
|
ctx.slots.inject("main", () => ctx.slots.register({
|
|
526
905
|
name: "main",
|
|
527
906
|
key: PANEL_ID
|
|
@@ -544,6 +923,26 @@ window.__ModuleLoader__.load({
|
|
|
544
923
|
|
|
545
924
|
exports.apply = apply;
|
|
546
925
|
exports.inject = inject;
|
|
926
|
+
exports.__test__ = {
|
|
927
|
+
ACCOUNT_UNKNOWN,
|
|
928
|
+
ACCOUNT_SIGNED_IN,
|
|
929
|
+
ACCOUNT_SIGNED_OUT,
|
|
930
|
+
CHAT_URL,
|
|
931
|
+
STORAGE_BASE,
|
|
932
|
+
accountCopy,
|
|
933
|
+
getSnapshot: () => accountSnapshot,
|
|
934
|
+
partitionKeyFor,
|
|
935
|
+
storageKeyFor,
|
|
936
|
+
publishAccount,
|
|
937
|
+
publishAccountLabel,
|
|
938
|
+
probeAccount,
|
|
939
|
+
callAccount,
|
|
940
|
+
resetAccountSync() {
|
|
941
|
+
accountSnapshot = { status: ACCOUNT_UNKNOWN, label: "", probed: false };
|
|
942
|
+
accountListeners.clear();
|
|
943
|
+
accountSyncStarted = false;
|
|
944
|
+
}
|
|
945
|
+
};
|
|
547
946
|
return module.exports;
|
|
548
947
|
}
|
|
549
948
|
});
|