@jaychang1989/dsh-webchat 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/NOTICE +18 -0
- package/README.md +78 -0
- package/README.zh.md +83 -0
- package/cordis.patch.yml +22 -0
- package/lib/client.js +1725 -0
- package/lib/client.js.map +1 -0
- package/lib/index.js +3887 -0
- package/lib/types/client/api.d.ts +74 -0
- package/lib/types/client/controller.d.ts +19 -0
- package/lib/types/client/index.d.ts +30 -0
- package/lib/types/client/locales.d.ts +169 -0
- package/lib/types/client/mount.d.ts +23 -0
- package/lib/types/client/panel/Markdown.d.ts +15 -0
- package/lib/types/client/panel/WebChatPanel.d.ts +28 -0
- package/lib/types/client/sidebar-entry-core.d.ts +60 -0
- package/lib/types/client/sidebar-entry.d.ts +22 -0
- package/lib/types/engine/engine.d.ts +247 -0
- package/lib/types/engine/html-md.d.ts +23 -0
- package/lib/types/index.d.ts +63 -0
- package/lib/types/protocol.d.ts +107 -0
- package/lib/types/routes.d.ts +22 -0
- package/lib/types/store.d.ts +69 -0
- package/lib/types/tools.d.ts +27 -0
- package/lib/types/transfer.d.ts +138 -0
- package/package.json +96 -0
- package/src/client/api.ts +90 -0
- package/src/client/controller.ts +43 -0
- package/src/client/css-modules.d.ts +5 -0
- package/src/client/index.ts +75 -0
- package/src/client/locales.ts +172 -0
- package/src/client/mount.tsx +125 -0
- package/src/client/panel/Markdown.tsx +251 -0
- package/src/client/panel/WebChatPanel.tsx +760 -0
- package/src/client/panel/panel.module.css +936 -0
- package/src/client/sidebar-entry-core.ts +207 -0
- package/src/client/sidebar-entry.ts +49 -0
- package/src/engine/engine.ts +1385 -0
- package/src/engine/html-md.ts +126 -0
- package/src/index.ts +212 -0
- package/src/protocol.ts +116 -0
- package/src/routes.ts +307 -0
- package/src/store.ts +217 -0
- package/src/tools.ts +257 -0
- package/src/transfer.ts +602 -0
|
@@ -0,0 +1,1385 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DeepSeek web engine — the "Codex ChatGPT mode" analog for DeepSeek Harness.
|
|
3
|
+
*
|
|
4
|
+
* Drives a real browser (system Chrome/Edge via playwright-core) against
|
|
5
|
+
* chat.deepseek.com with a dedicated persistent profile, so the user logs in
|
|
6
|
+
* once with their own DeepSeek account (phone / password / Apple / WeChat QR)
|
|
7
|
+
* and the session persists. Chatting happens THROUGH the real web page —
|
|
8
|
+
* messages are typed into the real composer — so the plugin needs no API key,
|
|
9
|
+
* no billing, and stays immune to DeepSeek's private-API PoW challenge.
|
|
10
|
+
*
|
|
11
|
+
* Replies are read by teeing the page's own SSE stream: an injected init
|
|
12
|
+
* script wraps XMLHttpRequest and captures the `/api/v0/chat/completion`
|
|
13
|
+
* response as it streams (the page has already solved PoW + auth, so we get
|
|
14
|
+
* the model's raw markdown for free). DOM scraping of `.ds-markdown` is kept
|
|
15
|
+
* only as a fallback for when the capture cannot install. The web chat runs
|
|
16
|
+
* the `deepseek-chat` model by default (switchable to deepseek-reasoner).
|
|
17
|
+
*
|
|
18
|
+
* All page interactions are best-effort and selector-defensive: failures
|
|
19
|
+
* produce readable errors (never crashes) and the caller decides how to
|
|
20
|
+
* degrade.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { existsSync, mkdirSync } from 'node:fs'
|
|
24
|
+
import { join } from 'node:path'
|
|
25
|
+
import { randomUUID } from 'node:crypto'
|
|
26
|
+
import { chromium, type BrowserContext, type Page } from 'playwright-core'
|
|
27
|
+
import type { TranscriptStore } from '../store.ts'
|
|
28
|
+
import type { EngineState, SendResult, WebChatErrorCode, WebChatMessage, WebChatTranscript } from '../protocol.ts'
|
|
29
|
+
import { serializeToMarkdown } from './html-md.ts'
|
|
30
|
+
|
|
31
|
+
/** Engine configuration (resolved from the plugin settings surface). */
|
|
32
|
+
export interface WebChatEngineConfig {
|
|
33
|
+
/** Data dir root (browser profile lives under it). */
|
|
34
|
+
dataDir: string
|
|
35
|
+
/** Browser channel hint: 'chrome' | 'msedge' | 'chromium' | undefined (auto-detect). */
|
|
36
|
+
channel?: string
|
|
37
|
+
/** Explicit browser executable path (overrides channel detection). */
|
|
38
|
+
executablePath?: string
|
|
39
|
+
/** Proxy mode: 'direct' (--no-proxy-server), 'system' (browser default), or a proxy URL. */
|
|
40
|
+
proxy?: string
|
|
41
|
+
/** Visible browser window (required for the one-time login; defaults true). */
|
|
42
|
+
headless?: boolean
|
|
43
|
+
/** Max seconds to wait for a reply before returning the partial. */
|
|
44
|
+
replyTimeoutMs?: number
|
|
45
|
+
/** DeepSeek web origin. */
|
|
46
|
+
baseUrl?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** One scraped message from the page. */
|
|
50
|
+
interface ScrapedMessage {
|
|
51
|
+
role: 'user' | 'assistant'
|
|
52
|
+
parts: Array<{ kind: 'think' | 'body'; markdown: string; text: string }>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const DEFAULT_TIMEOUT_MS = 180_000
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Injected before any page script: tee the chat/completion XHR stream into
|
|
59
|
+
* `window.__wcStream`. The DeepSeek web app reads its reply through an
|
|
60
|
+
* XMLHttpRequest (POST /api/v0/chat/completion, responseType "text", SSE
|
|
61
|
+
* body), so wrapping XHR `progress` events captures the raw `event:`/`data:`
|
|
62
|
+
* stream exactly as the page receives it — no PoW, no auth, no selectors.
|
|
63
|
+
* The function must stay self-contained (playwright serializes its source).
|
|
64
|
+
*/
|
|
65
|
+
function streamCaptureInit(): void {
|
|
66
|
+
const w = window as unknown as {
|
|
67
|
+
__wcCaptureInstalled?: boolean
|
|
68
|
+
__wcStream?: StreamCapture
|
|
69
|
+
XMLHttpRequest: typeof XMLHttpRequest
|
|
70
|
+
fetch: typeof fetch
|
|
71
|
+
TextDecoder: typeof TextDecoder
|
|
72
|
+
Response: typeof Response
|
|
73
|
+
}
|
|
74
|
+
if (w.__wcCaptureInstalled === true) return
|
|
75
|
+
w.__wcCaptureInstalled = true
|
|
76
|
+
w.__wcStream = { text: '', done: false, started: false, status: 0, error: '' }
|
|
77
|
+
// Deliberately `any`: this function is serialized by playwright and executed
|
|
78
|
+
// in the page, so its signature is runtime JS rather than typed host code.
|
|
79
|
+
const X: any = w.XMLHttpRequest
|
|
80
|
+
const origOpen: any = X.prototype.open
|
|
81
|
+
const origSend: any = X.prototype.send
|
|
82
|
+
X.prototype.open = function (this: any, method: string, url: string | URL, ...rest: any[]) {
|
|
83
|
+
this.__wcIsChat = typeof url === 'string' && url.includes('/chat/completion')
|
|
84
|
+
return origOpen.call(this, method, url, ...rest)
|
|
85
|
+
}
|
|
86
|
+
X.prototype.send = function (this: any, ...args: any[]) {
|
|
87
|
+
if (this.__wcIsChat === true) {
|
|
88
|
+
let lastLen = 0
|
|
89
|
+
this.addEventListener('progress', () => {
|
|
90
|
+
const stream = w.__wcStream
|
|
91
|
+
if (stream === undefined) return
|
|
92
|
+
const text = this.responseText ?? ''
|
|
93
|
+
if (text.length > lastLen) {
|
|
94
|
+
stream.text += text.slice(lastLen)
|
|
95
|
+
lastLen = text.length
|
|
96
|
+
}
|
|
97
|
+
stream.started = true
|
|
98
|
+
})
|
|
99
|
+
this.addEventListener('loadend', () => {
|
|
100
|
+
const stream = w.__wcStream
|
|
101
|
+
if (stream === undefined) return
|
|
102
|
+
stream.done = true
|
|
103
|
+
stream.status = this.status
|
|
104
|
+
if (this.status >= 400) stream.error = `HTTP ${this.status}`
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
return origSend.apply(this, args)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Also tee `fetch` — newer page builds may switch from XHR to fetch for the
|
|
111
|
+
// same /chat/completion stream. response.body.tee() mirrors the stream to the
|
|
112
|
+
// page untouched while we read the twin for capture. A capture failure must
|
|
113
|
+
// never break the page's own consumption, so every step is try/caught.
|
|
114
|
+
const origFetch: any = w.fetch.bind(w)
|
|
115
|
+
w.fetch = function (this: any, input: any, init: any) {
|
|
116
|
+
const url = typeof input === 'string' ? input : (input?.url ?? String(input))
|
|
117
|
+
const isChat = typeof url === 'string' && url.includes('/chat/completion')
|
|
118
|
+
return origFetch(input, init).then((response: any) => {
|
|
119
|
+
if (!isChat || response === null || response === undefined) return response
|
|
120
|
+
const body = response.body
|
|
121
|
+
if (body === null || body === undefined || typeof body.tee !== 'function') return response
|
|
122
|
+
try {
|
|
123
|
+
const [pageStream, captureStream] = body.tee()
|
|
124
|
+
const decoder = new w.TextDecoder()
|
|
125
|
+
const reader = captureStream.getReader()
|
|
126
|
+
void (async () => {
|
|
127
|
+
try {
|
|
128
|
+
for (;;) {
|
|
129
|
+
const { done, value } = await reader.read()
|
|
130
|
+
if (done) break
|
|
131
|
+
const stream = w.__wcStream
|
|
132
|
+
if (stream !== undefined) {
|
|
133
|
+
stream.text += decoder.decode(value, { stream: true })
|
|
134
|
+
stream.started = true
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const stream = w.__wcStream
|
|
138
|
+
if (stream !== undefined) {
|
|
139
|
+
stream.text += decoder.decode()
|
|
140
|
+
stream.done = true
|
|
141
|
+
stream.status = response.status
|
|
142
|
+
if (response.status >= 400) stream.error = `HTTP ${response.status}`
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
// capture failure must never break the page's own consumption
|
|
146
|
+
}
|
|
147
|
+
})()
|
|
148
|
+
return new w.Response(pageStream, {
|
|
149
|
+
status: response.status,
|
|
150
|
+
statusText: response.statusText,
|
|
151
|
+
headers: response.headers,
|
|
152
|
+
})
|
|
153
|
+
} catch {
|
|
154
|
+
return response
|
|
155
|
+
}
|
|
156
|
+
})
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Live capture buffer shape (mirrors window.__wcStream). */
|
|
161
|
+
interface StreamCapture {
|
|
162
|
+
text: string
|
|
163
|
+
done: boolean
|
|
164
|
+
started: boolean
|
|
165
|
+
status: number
|
|
166
|
+
error: string
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Parsed reply from the accumulated SSE text. */
|
|
170
|
+
export interface ParsedStreamReply {
|
|
171
|
+
/** Markdown body (thinking wrapped in a <details> block). */
|
|
172
|
+
markdown: string
|
|
173
|
+
/** Raw thinking text (empty when the model has none). */
|
|
174
|
+
thinking: string
|
|
175
|
+
/** True once the stream reported FINISHED. */
|
|
176
|
+
finished: boolean
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* DeepSeek citation numbering. The web numbers the *sources* (search results)
|
|
181
|
+
* 1..M, not the `[reference:N]` markers. Each `[reference:N]` marker is paired
|
|
182
|
+
* with a `references` op `{id,type}`:
|
|
183
|
+
* - `TOOL_OPEN` → a specific opened page whose `result.url` matches one of
|
|
184
|
+
* the search results; the citation number is that result's
|
|
185
|
+
* 1-based position in the search-results list.
|
|
186
|
+
* - `TOOL_SEARCH` → the search step itself, rendered by the web as a search
|
|
187
|
+
* icon (no number) rather than a citation.
|
|
188
|
+
* This is resolved inside `parseStreamReply`, which holds the search-results
|
|
189
|
+
* list and the opened-page id→url map.
|
|
190
|
+
*/
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Defensive clean-up of the DeepSeek search-agent trace tokens. The parser
|
|
194
|
+
* already routes `DEEP_SEARCH` (conversation_mode) and `FINISHED` (status)
|
|
195
|
+
* events away from content, so this normally runs as a no-op; it exists for
|
|
196
|
+
* the DOM-scrape fallback and any residual markers.
|
|
197
|
+
*/
|
|
198
|
+
function stripSearchTrace(text: string): string {
|
|
199
|
+
return text
|
|
200
|
+
.replace(/DEEP_SEARCH/g, '')
|
|
201
|
+
.replace(/FINISHED+/g, '\n\n')
|
|
202
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
203
|
+
.trim()
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** True when a fragment type is the R1 reasoning (THINK / THINKING). */
|
|
207
|
+
function isThinkingType(type: unknown): boolean {
|
|
208
|
+
return typeof type === 'string' && type.toUpperCase().includes('THINK')
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Parse the accumulated `/api/v0/chat/completion` SSE body into reply text.
|
|
213
|
+
* The stream is `event:` / `data:` lines; each `data:` payload is JSON. The
|
|
214
|
+
* protocol distinguishes the R1 reasoning fragment (type `THINK`) from the
|
|
215
|
+
* answer fragment (type `RESPONSE`), and carries search steps as `TOOL_SEARCH`
|
|
216
|
+
* / `TOOL_OPEN` fragments:
|
|
217
|
+
* - {"v":{"response":{"fragments":[{"type":"THINK","content":"…"}]}}}
|
|
218
|
+
* a snapshot carrying the fragment list and their types.
|
|
219
|
+
* - {"p":"response/fragments","o":"APPEND","v":[{"type":"RESPONSE",…}]}
|
|
220
|
+
* appends a NEW fragment (reasoning / search / answer); `-1/content`
|
|
221
|
+
* deltas after this belong to that new fragment.
|
|
222
|
+
* - {"p":"response/fragments/-1/content","o":"APPEND","v":"是一座"} — appends
|
|
223
|
+
* a text delta to the CURRENT fragment's content.
|
|
224
|
+
* - {"p":"response/fragments/-1/results","o":"SET","v":[…]} — search results
|
|
225
|
+
* for a TOOL_SEARCH step (rendered as "搜索到 N 个网页").
|
|
226
|
+
* - {"v":"将"} — a bare delta continuing the current fragment; a bare
|
|
227
|
+
* `{"v":[{p:"content",o:"APPEND",v:"[reference:N]"},…]}` carries citation
|
|
228
|
+
* markers.
|
|
229
|
+
* - {"p":"response/status","o":"SET","v":"FINISHED"} — generation complete.
|
|
230
|
+
*/
|
|
231
|
+
export function parseStreamReply(raw: string): ParsedStreamReply {
|
|
232
|
+
let body = ''
|
|
233
|
+
let thinking = ''
|
|
234
|
+
let finished = false
|
|
235
|
+
let currentType: unknown = 'RESPONSE'
|
|
236
|
+
|
|
237
|
+
// Citation-resolution state. `searchResults` holds the URLs of every search
|
|
238
|
+
// result in stream order (1-based position = the web's citation number);
|
|
239
|
+
// `openById` maps a TOOL_OPEN fragment id → its opened page url.
|
|
240
|
+
const searchResults: string[] = []
|
|
241
|
+
const openById = new Map<number, string>()
|
|
242
|
+
let urlToIndex: Map<string, number> | undefined
|
|
243
|
+
|
|
244
|
+
const buildUrlIndex = (): Map<string, number> => {
|
|
245
|
+
if (urlToIndex === undefined) {
|
|
246
|
+
urlToIndex = new Map()
|
|
247
|
+
searchResults.forEach((url, i) => {
|
|
248
|
+
if (url !== '' && !urlToIndex!.has(url)) urlToIndex!.set(url, i)
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
return urlToIndex
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Resolve `[reference:N]` markers to `[citation:K]` using their paired
|
|
255
|
+
// `references` op. TOOL_OPEN → the search result's 1-based number;
|
|
256
|
+
// TOOL_SEARCH (and anything unresolved) → dropped (the web shows an icon).
|
|
257
|
+
const resolveCitations = (text: string, refs: unknown[] | null): string => {
|
|
258
|
+
if (!Array.isArray(refs) || refs.length === 0) return text
|
|
259
|
+
let i = 0
|
|
260
|
+
return text.replace(/\[reference:\d+\]/g, () => {
|
|
261
|
+
const ref = refs[i] as Record<string, unknown> | undefined
|
|
262
|
+
i++
|
|
263
|
+
if (typeof ref !== 'object' || ref === null) return ''
|
|
264
|
+
if (ref['type'] !== 'TOOL_OPEN') return ''
|
|
265
|
+
const id = ref['id']
|
|
266
|
+
const url = typeof id === 'number' ? openById.get(id) : undefined
|
|
267
|
+
if (url === undefined) return ''
|
|
268
|
+
const idx = buildUrlIndex().get(url)
|
|
269
|
+
return idx === undefined ? '' : `[citation:${idx + 1}]`
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Route a content delta to the reasoning (R1) or the answer body. Citation
|
|
274
|
+
// markers are resolved only inside `applyBatchOps`, which has the paired
|
|
275
|
+
// `references` op; plain deltas never carry them.
|
|
276
|
+
const appendContent = (text: string): void => {
|
|
277
|
+
if (isThinkingType(currentType)) thinking += text
|
|
278
|
+
else body += text
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Append newly-arrived fragments, tracking the current type and rendering
|
|
282
|
+
// TOOL_OPEN steps as a "浏览 N 个页面" status block.
|
|
283
|
+
const appendFragments = (fragments: unknown[]): void => {
|
|
284
|
+
const opened: string[] = []
|
|
285
|
+
for (const frag of fragments) {
|
|
286
|
+
if (typeof frag !== 'object' || frag === null) continue
|
|
287
|
+
const f = frag as Record<string, unknown>
|
|
288
|
+
const type = f['type']
|
|
289
|
+
if (typeof type === 'string') currentType = type
|
|
290
|
+
const content = f['content']
|
|
291
|
+
if (typeof content === 'string' && content !== '') {
|
|
292
|
+
if (isThinkingType(type)) thinking += content
|
|
293
|
+
else if (type === 'RESPONSE' || type === 'TEXT') body += content
|
|
294
|
+
}
|
|
295
|
+
if (type === 'TOOL_OPEN') {
|
|
296
|
+
const result = f['result'] as Record<string, unknown> | undefined
|
|
297
|
+
const title = result?.['title']
|
|
298
|
+
if (typeof title === 'string' && title !== '') opened.push(title)
|
|
299
|
+
const id = f['id']
|
|
300
|
+
const url = result?.['url']
|
|
301
|
+
if (typeof id === 'number' && typeof url === 'string' && url !== '') {
|
|
302
|
+
openById.set(id, url)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (opened.length > 0) {
|
|
307
|
+
thinking += `\n\n浏览 ${opened.length} 个页面\n${opened.map(t => `- ${t}`).join('\n')}\n\n`
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Apply the ops inside a BATCH payload (bare or path-addressed). Citation
|
|
312
|
+
// batches pair a `content` APPEND (`[reference:N]`) with a `references`
|
|
313
|
+
// op, so collect them and resolve after the loop.
|
|
314
|
+
const applyBatchOps = (ops: unknown[]): void => {
|
|
315
|
+
let contentText = ''
|
|
316
|
+
let hasContent = false
|
|
317
|
+
let refs: unknown[] | null = null
|
|
318
|
+
const fragmentsList: unknown[][] = []
|
|
319
|
+
for (const item of ops) {
|
|
320
|
+
if (typeof item !== 'object' || item === null) continue
|
|
321
|
+
const it = item as Record<string, unknown>
|
|
322
|
+
const ip = it['p']
|
|
323
|
+
const iop = it['o']
|
|
324
|
+
const iv = it['v']
|
|
325
|
+
if (ip === 'content' && iop === 'APPEND' && typeof iv === 'string') {
|
|
326
|
+
contentText += iv
|
|
327
|
+
hasContent = true
|
|
328
|
+
} else if (ip === 'references' && Array.isArray(iv)) {
|
|
329
|
+
refs = iv
|
|
330
|
+
} else if (ip === 'fragments' && iop === 'APPEND' && Array.isArray(iv)) {
|
|
331
|
+
fragmentsList.push(iv)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
for (const fr of fragmentsList) appendFragments(fr)
|
|
335
|
+
if (hasContent) {
|
|
336
|
+
if (isThinkingType(currentType)) thinking += contentText
|
|
337
|
+
else body += resolveCitations(contentText, refs)
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
342
|
+
const trimmed = line.trim()
|
|
343
|
+
if (!trimmed.startsWith('data:')) continue
|
|
344
|
+
const payload = trimmed.slice(5).trim()
|
|
345
|
+
if (payload === '') continue
|
|
346
|
+
let obj: unknown
|
|
347
|
+
try {
|
|
348
|
+
obj = JSON.parse(payload)
|
|
349
|
+
} catch {
|
|
350
|
+
continue
|
|
351
|
+
}
|
|
352
|
+
if (typeof obj !== 'object' || obj === null) continue
|
|
353
|
+
const o = obj as Record<string, unknown>
|
|
354
|
+
const v = o['v']
|
|
355
|
+
|
|
356
|
+
// 1. Snapshot — the full fragment list (normally only at reply start).
|
|
357
|
+
if (typeof v === 'object' && v !== null && !Array.isArray(v) && 'response' in v) {
|
|
358
|
+
const resp = (v as Record<string, unknown>)['response'] as Record<string, unknown> | undefined
|
|
359
|
+
const fragments = resp?.['fragments']
|
|
360
|
+
if (Array.isArray(fragments)) {
|
|
361
|
+
let newBody = ''
|
|
362
|
+
let newThinking = ''
|
|
363
|
+
for (const frag of fragments) {
|
|
364
|
+
if (typeof frag !== 'object' || frag === null) continue
|
|
365
|
+
const f = frag as Record<string, unknown>
|
|
366
|
+
const type = f['type']
|
|
367
|
+
if (typeof type === 'string') currentType = type
|
|
368
|
+
const content = f['content']
|
|
369
|
+
if (typeof content !== 'string' || content === '') continue
|
|
370
|
+
if (isThinkingType(type)) newThinking += content
|
|
371
|
+
else if (type === 'RESPONSE' || type === 'TEXT') newBody += content
|
|
372
|
+
}
|
|
373
|
+
if (newBody !== '') body = newBody
|
|
374
|
+
if (newThinking !== '') thinking = newThinking
|
|
375
|
+
}
|
|
376
|
+
continue
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const p = o['p']
|
|
380
|
+
const op = o['o']
|
|
381
|
+
|
|
382
|
+
// 2. Direct fragment append — a new fragment (reasoning / search / answer).
|
|
383
|
+
if (p === 'response/fragments' && op === 'APPEND' && Array.isArray(v)) {
|
|
384
|
+
appendFragments(v)
|
|
385
|
+
continue
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// 3. Path-addressed events — content deltas, search results, status, mode.
|
|
389
|
+
if (typeof p === 'string') {
|
|
390
|
+
// A `-1/content` delta may omit the `o` field (implied APPEND); accept it
|
|
391
|
+
// whenever `o` is absent or "APPEND".
|
|
392
|
+
if (typeof v === 'string' && p === 'response/fragments/-1/content' && op !== 'SET') {
|
|
393
|
+
appendContent(v)
|
|
394
|
+
} else if (op === 'SET' && p === 'response/fragments/-1/results' && Array.isArray(v)) {
|
|
395
|
+
for (const r of v) {
|
|
396
|
+
if (typeof r === 'object' && r !== null) {
|
|
397
|
+
const url = (r as Record<string, unknown>)['url']
|
|
398
|
+
if (typeof url === 'string' && url !== '') searchResults.push(url)
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
thinking += `\n\n搜索到 ${v.length} 个网页\n\n`
|
|
402
|
+
} else if (op === 'SET' && p === 'response/status' && v === 'FINISHED') {
|
|
403
|
+
finished = true
|
|
404
|
+
} else if (op === 'BATCH' && Array.isArray(v)) {
|
|
405
|
+
applyBatchOps(v)
|
|
406
|
+
}
|
|
407
|
+
// conversation_mode / elapsed_secs / fragment status FINISHED / references → ignored
|
|
408
|
+
continue
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// 4. Bare batch — {"v":[{p:"content",o:"APPEND",v:"[reference:N]"},…]}
|
|
412
|
+
if (Array.isArray(v)) {
|
|
413
|
+
applyBatchOps(v)
|
|
414
|
+
continue
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// 5. Bare delta — {"v":"…"} continues the current fragment.
|
|
418
|
+
if (typeof v === 'string') appendContent(v)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const thinkMd = thinking.trim() === ''
|
|
422
|
+
? ''
|
|
423
|
+
: `<details><summary>思考过程</summary>\n\n${thinking.trim()}\n\n</details>`
|
|
424
|
+
const markdown = [thinkMd, body.trim()].filter(s => s !== '').join('\n\n')
|
|
425
|
+
return { markdown, thinking, finished }
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Order-preserving promise queue — the browser page handles one chat op at a time. */
|
|
429
|
+
class SerialQueue {
|
|
430
|
+
private tail: Promise<unknown> = Promise.resolve()
|
|
431
|
+
run<T>(task: () => Promise<T>): Promise<T> {
|
|
432
|
+
const next = this.tail.then(task, task)
|
|
433
|
+
this.tail = next.catch(() => undefined)
|
|
434
|
+
return next
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export class DeepSeekWebEngine {
|
|
439
|
+
private readonly store: TranscriptStore
|
|
440
|
+
private readonly config: WebChatEngineConfig
|
|
441
|
+
private readonly profileDir: string
|
|
442
|
+
private context: BrowserContext | undefined
|
|
443
|
+
private page: Page | undefined
|
|
444
|
+
private readonly queue = new SerialQueue()
|
|
445
|
+
private state: EngineState = 'stopped'
|
|
446
|
+
private engineError: string | undefined
|
|
447
|
+
private busy = false
|
|
448
|
+
private lastError: string | undefined
|
|
449
|
+
private lastErrorCode: WebChatErrorCode | undefined
|
|
450
|
+
private launchedOnce = false
|
|
451
|
+
/** True while a headed one-time login window is open (auto-closes on login). */
|
|
452
|
+
private loginMode = false
|
|
453
|
+
/** Remembered login state — survives the auto-close so the panel stays "已登录". */
|
|
454
|
+
private loggedInOnce = false
|
|
455
|
+
/** Commanded toggle state (best-effort read-back overrides on status). */
|
|
456
|
+
private deepThink = false
|
|
457
|
+
private search = false
|
|
458
|
+
|
|
459
|
+
constructor(store: TranscriptStore, config: WebChatEngineConfig) {
|
|
460
|
+
this.store = store
|
|
461
|
+
this.config = config
|
|
462
|
+
this.profileDir = join(config.dataDir, 'browser-profile')
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Coarse state for status snapshots. */
|
|
466
|
+
getState(): EngineState {
|
|
467
|
+
return this.state
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
getEngineError(): string | undefined {
|
|
471
|
+
return this.engineError
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
getBusy(): boolean {
|
|
475
|
+
return this.busy
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
getLastError(): string | undefined {
|
|
479
|
+
return this.lastError
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
getLastErrorCode(): WebChatErrorCode | undefined {
|
|
483
|
+
return this.lastErrorCode
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/** Set the last error + its structured code together (keeps them in sync). */
|
|
487
|
+
private setLastError(message: string | undefined, code?: WebChatErrorCode): void {
|
|
488
|
+
this.lastError = message
|
|
489
|
+
this.lastErrorCode = code
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
private setState(next: EngineState, error?: string): void {
|
|
493
|
+
this.state = next
|
|
494
|
+
this.engineError = error
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Resolve a browser launch descriptor (executable + args). */
|
|
498
|
+
private launchOptions(): { channel?: string; executablePath?: string; args: string[] } {
|
|
499
|
+
const args: string[] = []
|
|
500
|
+
const proxy = this.config.proxy ?? 'direct'
|
|
501
|
+
if (proxy === 'direct') args.push('--no-proxy-server')
|
|
502
|
+
else if (proxy.startsWith('http')) args.push(`--proxy-server=${proxy}`)
|
|
503
|
+
// channel wins; explicit path beats both.
|
|
504
|
+
if (this.config.executablePath !== undefined) return { executablePath: this.config.executablePath, args }
|
|
505
|
+
if (this.config.channel !== undefined && this.config.channel !== 'auto') return { channel: this.config.channel, args }
|
|
506
|
+
// Auto: probe the known system browsers in order (playwright channels first,
|
|
507
|
+
// then explicit paths on each OS).
|
|
508
|
+
const candidates: Array<{ channel?: string; executablePath?: string }> = [
|
|
509
|
+
{ channel: 'chrome' },
|
|
510
|
+
{ channel: 'msedge' },
|
|
511
|
+
{ channel: 'chromium' },
|
|
512
|
+
{ executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' },
|
|
513
|
+
{ executablePath: '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge' },
|
|
514
|
+
{ executablePath: '/usr/bin/google-chrome' },
|
|
515
|
+
{ executablePath: '/usr/bin/google-chrome-stable' },
|
|
516
|
+
{ executablePath: '/usr/bin/microsoft-edge' },
|
|
517
|
+
{ executablePath: '/usr/bin/chromium' },
|
|
518
|
+
{ executablePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' },
|
|
519
|
+
{ executablePath: 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe' },
|
|
520
|
+
]
|
|
521
|
+
return { ...candidates[0], args }
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Ensure the browser + chat.deepseek.com page exist. Launches the persistent
|
|
526
|
+
* context on first call; subsequent calls reuse the page.
|
|
527
|
+
*/
|
|
528
|
+
/** True when the cached page/context are still connected (not closed by the user). */
|
|
529
|
+
private isPageAlive(): boolean {
|
|
530
|
+
if (this.page === undefined || this.context === undefined) return false
|
|
531
|
+
try {
|
|
532
|
+
return !this.page.isClosed()
|
|
533
|
+
} catch {
|
|
534
|
+
return false
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
async ensureBrowser(): Promise<Page> {
|
|
539
|
+
if (this.isPageAlive()) return this.page!
|
|
540
|
+
if (this.state === 'launching') {
|
|
541
|
+
// Another call is already launching; wait for it.
|
|
542
|
+
for (let attempt = 0; attempt < 100 && !this.isPageAlive(); attempt++) {
|
|
543
|
+
await new Promise(resolve => setTimeout(resolve, 100))
|
|
544
|
+
}
|
|
545
|
+
if (this.isPageAlive()) return this.page!
|
|
546
|
+
throw new Error('浏览器启动超时')
|
|
547
|
+
}
|
|
548
|
+
// Clear any stale (dead) page/context before relaunching.
|
|
549
|
+
if (this.page !== undefined || this.context !== undefined) await this.disposeBrowser()
|
|
550
|
+
this.setState('launching')
|
|
551
|
+
try {
|
|
552
|
+
mkdirSync(this.profileDir, { recursive: true, mode: 0o700 })
|
|
553
|
+
const options = this.launchOptions()
|
|
554
|
+
const attemptOrder: Array<{ channel?: string; executablePath?: string }> =
|
|
555
|
+
this.config.executablePath !== undefined || (this.config.channel !== undefined && this.config.channel !== 'auto')
|
|
556
|
+
? [options]
|
|
557
|
+
: this.launchOptionsCandidates()
|
|
558
|
+
let lastError: unknown
|
|
559
|
+
for (const attempt of attemptOrder) {
|
|
560
|
+
try {
|
|
561
|
+
this.context = await chromium.launchPersistentContext(this.profileDir, {
|
|
562
|
+
...attempt,
|
|
563
|
+
// The one-time login window must be visible; normal (chat) launches
|
|
564
|
+
// are headless by default so the browser stays out of the way.
|
|
565
|
+
headless: this.loginMode ? false : (this.config.headless ?? true),
|
|
566
|
+
viewport: null,
|
|
567
|
+
args: options.args,
|
|
568
|
+
})
|
|
569
|
+
lastError = undefined
|
|
570
|
+
break
|
|
571
|
+
} catch (error) {
|
|
572
|
+
lastError = error
|
|
573
|
+
await this.disposeBrowser()
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
if (lastError !== undefined) {
|
|
577
|
+
this.setState('error', `无法启动浏览器(请检查 Chrome/Edge 是否已安装,或在插件设置中指定可执行文件路径): ${String(lastError)}`)
|
|
578
|
+
throw new Error(this.engineError)
|
|
579
|
+
}
|
|
580
|
+
const pages = this.context!.pages()
|
|
581
|
+
this.page = pages[0] ?? (await this.context!.newPage())
|
|
582
|
+
this.page.setDefaultTimeout(15_000)
|
|
583
|
+
await this.page.addInitScript(streamCaptureInit)
|
|
584
|
+
await this.openDeepSeekPage()
|
|
585
|
+
this.setState('ready')
|
|
586
|
+
this.launchedOnce = true
|
|
587
|
+
return this.page
|
|
588
|
+
} catch (error) {
|
|
589
|
+
if (this.state !== 'error') this.setState('error', String(error))
|
|
590
|
+
throw error
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** The candidate list used during auto-detection. */
|
|
595
|
+
private launchOptionsCandidates(): Array<{ channel?: string; executablePath?: string }> {
|
|
596
|
+
const all: Array<{ channel?: string; executablePath?: string }> = [
|
|
597
|
+
{ channel: 'chrome' },
|
|
598
|
+
{ channel: 'msedge' },
|
|
599
|
+
{ channel: 'chromium' },
|
|
600
|
+
{ executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' },
|
|
601
|
+
{ executablePath: '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge' },
|
|
602
|
+
{ executablePath: '/usr/bin/google-chrome' },
|
|
603
|
+
{ executablePath: '/usr/bin/google-chrome-stable' },
|
|
604
|
+
{ executablePath: '/usr/bin/microsoft-edge' },
|
|
605
|
+
{ executablePath: '/usr/bin/chromium' },
|
|
606
|
+
{ executablePath: 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe' },
|
|
607
|
+
{ executablePath: 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe' },
|
|
608
|
+
]
|
|
609
|
+
return all.filter(candidate => {
|
|
610
|
+
if (candidate.channel !== undefined) return true
|
|
611
|
+
return candidate.executablePath !== undefined && existsSync(candidate.executablePath)
|
|
612
|
+
})
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** Navigate to the DeepSeek chat root. */
|
|
616
|
+
private async openDeepSeekPage(): Promise<void> {
|
|
617
|
+
if (this.page === undefined) throw new Error('浏览器尚未启动')
|
|
618
|
+
const baseUrl = this.config.baseUrl ?? 'https://chat.deepseek.com'
|
|
619
|
+
try {
|
|
620
|
+
await this.page.goto(baseUrl, { waitUntil: 'domcontentloaded', timeout: 45_000 })
|
|
621
|
+
// Let the SPA settle; login redirects to /sign_in when not authenticated.
|
|
622
|
+
await this.page.waitForTimeout(2_500)
|
|
623
|
+
} catch (error) {
|
|
624
|
+
this.setState('error', `无法打开 ${baseUrl}:${String(error)}`)
|
|
625
|
+
throw new Error(this.engineError)
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** True when the page shows the chat UI (not the login page). */
|
|
630
|
+
async isLoggedIn(): Promise<boolean | null> {
|
|
631
|
+
if (!this.isPageAlive()) return null
|
|
632
|
+
try {
|
|
633
|
+
const url = this.page!.url()
|
|
634
|
+
if (url.includes('/sign_in') || url.includes('/auth')) return false
|
|
635
|
+
const hasComposer = await this.page!.locator('textarea').count().then(count => count > 0).catch(() => false)
|
|
636
|
+
return hasComposer
|
|
637
|
+
} catch {
|
|
638
|
+
return null
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* Open a visible browser window for the one-time login. The window is forced
|
|
644
|
+
* headed (login needs a user) and auto-closes as soon as the page reaches the
|
|
645
|
+
* chat UI; normal chatting then runs headless on the persisted profile.
|
|
646
|
+
*/
|
|
647
|
+
async openLoginWindow(): Promise<{ ok: boolean; error?: string }> {
|
|
648
|
+
try {
|
|
649
|
+
// Close any running (possibly headless) browser first so we always open a
|
|
650
|
+
// fresh *visible* window for the one-time login.
|
|
651
|
+
if (this.page !== undefined || this.context !== undefined) await this.disposeBrowser()
|
|
652
|
+
this.loginMode = true
|
|
653
|
+
await this.ensureBrowser()
|
|
654
|
+
await this.page?.bringToFront()
|
|
655
|
+
void this.watchLoginAndClose()
|
|
656
|
+
return { ok: true }
|
|
657
|
+
} catch (error) {
|
|
658
|
+
this.loginMode = false
|
|
659
|
+
return { ok: false, error: String(error) }
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** Poll the login window and close it once the user has logged in. */
|
|
664
|
+
private async watchLoginAndClose(): Promise<void> {
|
|
665
|
+
for (let attempt = 0; attempt < 600; attempt++) {
|
|
666
|
+
if (!this.loginMode) return
|
|
667
|
+
if (!this.isPageAlive()) {
|
|
668
|
+
// The user closed the window manually; stop watching.
|
|
669
|
+
this.loginMode = false
|
|
670
|
+
return
|
|
671
|
+
}
|
|
672
|
+
if (await this.isLoggedIn() === true) {
|
|
673
|
+
this.loginMode = false
|
|
674
|
+
this.loggedInOnce = true
|
|
675
|
+
await this.disposeBrowser().catch(() => undefined)
|
|
676
|
+
return
|
|
677
|
+
}
|
|
678
|
+
await new Promise(resolve => setTimeout(resolve, 1000))
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/** Current page URL (for status/debug). */
|
|
683
|
+
pageUrl(): string | undefined {
|
|
684
|
+
if (!this.isPageAlive()) return undefined
|
|
685
|
+
try {
|
|
686
|
+
return this.page?.url()
|
|
687
|
+
} catch {
|
|
688
|
+
return undefined
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Best-effort read of the deep-think (R1) and search toggle state from the
|
|
694
|
+
* page. The toggles are `div.ds-toggle-button` elements (NOT `<button>`)
|
|
695
|
+
* carrying `aria-pressed` plus a `ds-toggle-button--selected` class when on;
|
|
696
|
+
* the search toggle is labeled 智能搜索. Falls back to the last commanded
|
|
697
|
+
* state when the page gives no clear signal.
|
|
698
|
+
*/
|
|
699
|
+
private async readToggles(): Promise<{ deepThink: boolean; search: boolean }> {
|
|
700
|
+
if (!this.isPageAlive()) return { deepThink: this.deepThink, search: this.search }
|
|
701
|
+
try {
|
|
702
|
+
const pageState = await this.page!.evaluate(() => {
|
|
703
|
+
const read = (candidates: string[]): boolean | undefined => {
|
|
704
|
+
for (const el of Array.from(document.querySelectorAll<HTMLElement>('[aria-pressed]'))) {
|
|
705
|
+
const label = `${el.textContent ?? ''} ${el.getAttribute('aria-label') ?? ''}`
|
|
706
|
+
if (!candidates.some(candidate => label.includes(candidate))) continue
|
|
707
|
+
const pressed = el.getAttribute('aria-pressed')
|
|
708
|
+
if (pressed === 'true') return true
|
|
709
|
+
if (pressed === 'false') return false
|
|
710
|
+
const cls = typeof el.className === 'string' ? el.className : ''
|
|
711
|
+
if (/ds-toggle-button--selected|--selected|active|checked/i.test(cls)) return true
|
|
712
|
+
}
|
|
713
|
+
return undefined
|
|
714
|
+
}
|
|
715
|
+
return {
|
|
716
|
+
deepThink: read(['深度思考', 'DeepThink', 'Deep Think', 'R1']),
|
|
717
|
+
search: read(['智能搜索', '联网搜索', '搜索', 'Search']),
|
|
718
|
+
}
|
|
719
|
+
})
|
|
720
|
+
return {
|
|
721
|
+
deepThink: pageState.deepThink ?? this.deepThink,
|
|
722
|
+
search: pageState.search ?? this.search,
|
|
723
|
+
}
|
|
724
|
+
} catch {
|
|
725
|
+
return { deepThink: this.deepThink, search: this.search }
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/** Serialized page evaluation guarded against a dead page. */
|
|
730
|
+
private async evalPage<T>(fn: () => T | Promise<T>): Promise<T> {
|
|
731
|
+
if (this.page === undefined) throw new Error('浏览器尚未启动')
|
|
732
|
+
return this.page.evaluate(fn)
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/**
|
|
736
|
+
* In-page scraper: returns the ordered rendered messages currently in the
|
|
737
|
+
* DOM. Uses the virtual-list item keys as message boundaries and the
|
|
738
|
+
* assistant-main-content class to split roles. Fallback only — the primary
|
|
739
|
+
* reply source is the teed SSE stream.
|
|
740
|
+
*/
|
|
741
|
+
private async scrapeConversation(): Promise<ScrapedMessage[]> {
|
|
742
|
+
if (this.page === undefined) return []
|
|
743
|
+
interface RawMessage {
|
|
744
|
+
role: 'user' | 'assistant'
|
|
745
|
+
parts: Array<{ kind: 'think' | 'body'; markdown: string; text: string }>
|
|
746
|
+
}
|
|
747
|
+
const raw = await this.page.evaluate((): RawMessage[] => {
|
|
748
|
+
const extract = (element: Element): { markdown: string; text: string } => {
|
|
749
|
+
const clone = element.cloneNode(true) as HTMLElement
|
|
750
|
+
for (const junk of clone.querySelectorAll('.ds-markdown-code-copy-button, button, svg, [class*="copy"]')) {
|
|
751
|
+
junk.remove()
|
|
752
|
+
}
|
|
753
|
+
return { markdown: clone.innerHTML, text: clone.innerText }
|
|
754
|
+
}
|
|
755
|
+
const out: RawMessage[] = []
|
|
756
|
+
const items = document.querySelectorAll('[data-virtual-list-item-key]')
|
|
757
|
+
for (const item of Array.from(items)) {
|
|
758
|
+
const assistant = item.querySelector('.ds-assistant-message-main-content')
|
|
759
|
+
if (assistant !== null) {
|
|
760
|
+
const parts: Array<{ kind: 'think' | 'body'; markdown: string; text: string }> = []
|
|
761
|
+
const think = item.querySelector('.ds-think-content')
|
|
762
|
+
if (think !== null) {
|
|
763
|
+
parts.push({ kind: 'think', ...extract(think) })
|
|
764
|
+
}
|
|
765
|
+
// The assistant container may itself carry .ds-markdown (current
|
|
766
|
+
// DOM) or contain a .ds-markdown descendant (older DOM).
|
|
767
|
+
const body = assistant.classList.contains('ds-markdown')
|
|
768
|
+
? assistant
|
|
769
|
+
: assistant.querySelector('.ds-markdown')
|
|
770
|
+
if (body !== null) parts.push({ kind: 'body', ...extract(body) })
|
|
771
|
+
if (parts.length > 0) out.push({ role: 'assistant', parts })
|
|
772
|
+
} else {
|
|
773
|
+
// User message: no .ds-markdown wrapper in the current DOM.
|
|
774
|
+
const clone = item.cloneNode(true) as HTMLElement
|
|
775
|
+
for (const junk of clone.querySelectorAll('button, svg, [class*="copy"]')) {
|
|
776
|
+
junk.remove()
|
|
777
|
+
}
|
|
778
|
+
const text = (clone.innerText ?? '').trim()
|
|
779
|
+
if (text !== '') out.push({ role: 'user', parts: [{ kind: 'body', markdown: '', text }] })
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
return out
|
|
783
|
+
})
|
|
784
|
+
return raw
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/** Convert scraped DOM messages into transcript messages (markdown content). */
|
|
788
|
+
private scrapedToMessages(scraped: ScrapedMessage[]): WebChatMessage[] {
|
|
789
|
+
return scraped.map(message => {
|
|
790
|
+
if (message.role === 'user') {
|
|
791
|
+
const text = message.parts.map(part => part.text).join('\n\n').trim()
|
|
792
|
+
return { id: randomUUID(), role: 'user', content: text === '' ? '(无内容)' : text, ts: Date.now() }
|
|
793
|
+
}
|
|
794
|
+
const think = message.parts.filter(part => part.kind === 'think').map(part => part.text).join('\n\n').trim()
|
|
795
|
+
const bodyHtml = message.parts.find(part => part.kind === 'body')?.markdown ?? ''
|
|
796
|
+
const bodyMd = bodyHtml === '' ? '' : serializeToMarkdown(parseMarkup(bodyHtml))
|
|
797
|
+
const thinkMd = think === '' ? '' : `<details><summary>思考过程</summary>\n\n${think}\n\n</details>`
|
|
798
|
+
return { id: randomUUID(), role: 'assistant', content: [thinkMd, bodyMd].filter(Boolean).join('\n\n'), ts: Date.now() }
|
|
799
|
+
})
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Scrape the DeepSeek sidebar conversation titles (best effort). The web
|
|
804
|
+
* conversation list has no stable contract, so several candidate selectors
|
|
805
|
+
* are probed and short non-menu texts are returned.
|
|
806
|
+
*/
|
|
807
|
+
async listWebConversations(): Promise<Array<{ title: string }>> {
|
|
808
|
+
if (this.page === undefined) return []
|
|
809
|
+
const titles = await this.page.evaluate(() => {
|
|
810
|
+
const found = new Set<string>()
|
|
811
|
+
const selectors = [
|
|
812
|
+
'[class*="conversation"]',
|
|
813
|
+
'[class*="chat-item"]',
|
|
814
|
+
'[class*="session-item"]',
|
|
815
|
+
'nav a',
|
|
816
|
+
'aside a',
|
|
817
|
+
'[class*="sidebar"] a',
|
|
818
|
+
]
|
|
819
|
+
for (const selector of selectors) {
|
|
820
|
+
for (const el of Array.from(document.querySelectorAll<HTMLElement>(selector))) {
|
|
821
|
+
const text = (el.textContent ?? '').trim().replace(/\s+/g, ' ')
|
|
822
|
+
if (text.length >= 1 && text.length <= 80 && !/^(新对话|New chat|删除|重命名|清空|设置|登出)/i.test(text)) {
|
|
823
|
+
found.add(text)
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
return Array.from(found).slice(0, 50)
|
|
828
|
+
}).catch(() => [] as string[])
|
|
829
|
+
return titles.map(title => ({ title }))
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/** Click a sidebar conversation whose text contains the given title. */
|
|
833
|
+
private async clickConversationByTitle(title: string): Promise<boolean> {
|
|
834
|
+
if (this.page === undefined) return false
|
|
835
|
+
const selectors = [
|
|
836
|
+
'[class*="conversation"]',
|
|
837
|
+
'[class*="chat-item"]',
|
|
838
|
+
'[class*="session-item"]',
|
|
839
|
+
'nav a',
|
|
840
|
+
'aside a',
|
|
841
|
+
]
|
|
842
|
+
for (const selector of selectors) {
|
|
843
|
+
const locator = this.page.locator(selector).filter({ hasText: title }).first()
|
|
844
|
+
if (await locator.count().catch(() => 0) > 0) {
|
|
845
|
+
await locator.click({ timeout: 5_000 }).catch(() => undefined)
|
|
846
|
+
return true
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
return false
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Recover a web conversation into the local transcript store: open it in the
|
|
854
|
+
* sidebar, scrape its history, and import it (idempotent by title).
|
|
855
|
+
*/
|
|
856
|
+
async recoverWebConversation(title: string): Promise<{ ok: boolean; chatId?: string; title?: string; created?: boolean; error?: string }> {
|
|
857
|
+
if (this.page === undefined) {
|
|
858
|
+
try {
|
|
859
|
+
await this.ensureBrowser()
|
|
860
|
+
} catch (error) {
|
|
861
|
+
return { ok: false, error: String(error) }
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
if (await this.isLoggedIn() !== true) {
|
|
865
|
+
return { ok: false, error: '尚未登录 DeepSeek 网页端' }
|
|
866
|
+
}
|
|
867
|
+
try {
|
|
868
|
+
const clicked = await this.clickConversationByTitle(title)
|
|
869
|
+
if (!clicked) return { ok: false, error: `未在网页端找到会话「${title}」` }
|
|
870
|
+
await this.page!.waitForTimeout(1_500)
|
|
871
|
+
const scraped = await this.scrapeConversation()
|
|
872
|
+
if (scraped.length === 0) return { ok: false, error: '读取网页会话历史失败(页面可能已改版)' }
|
|
873
|
+
const messages = this.scrapedToMessages(scraped)
|
|
874
|
+
const model = this.deepThink ? 'deepseek-reasoner' : 'deepseek-chat'
|
|
875
|
+
const result = this.store.importTranscript({ title, model, messages })
|
|
876
|
+
return { ok: true, chatId: result.chat.id, title: result.chat.title, created: result.created }
|
|
877
|
+
} catch (error) {
|
|
878
|
+
return { ok: false, error: String(error) }
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/** Detect whether the page is currently generating (stop affordance visible). */
|
|
883
|
+
private async isGenerating(): Promise<boolean> {
|
|
884
|
+
if (this.page === undefined) return false
|
|
885
|
+
try {
|
|
886
|
+
const stopSelectors = [
|
|
887
|
+
'button[aria-label*="停止"]',
|
|
888
|
+
'[aria-label*="stop generating" i]',
|
|
889
|
+
'button:has-text("停止生成")',
|
|
890
|
+
'button:has-text("Stop generating")',
|
|
891
|
+
]
|
|
892
|
+
for (const selector of stopSelectors) {
|
|
893
|
+
if (await this.page.locator(selector).count().catch(() => 0) > 0) return true
|
|
894
|
+
}
|
|
895
|
+
return false
|
|
896
|
+
} catch {
|
|
897
|
+
return false
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/** Click the stop-generation affordance, best effort. */
|
|
902
|
+
async stop(): Promise<void> {
|
|
903
|
+
await this.queue.run(async () => {
|
|
904
|
+
if (this.page === undefined) return
|
|
905
|
+
const stopSelectors = [
|
|
906
|
+
'button[aria-label*="停止"]',
|
|
907
|
+
'[aria-label*="stop generating" i]',
|
|
908
|
+
'button:has-text("停止生成")',
|
|
909
|
+
'button:has-text("Stop generating")',
|
|
910
|
+
]
|
|
911
|
+
for (const selector of stopSelectors) {
|
|
912
|
+
const locator = this.page.locator(selector).first()
|
|
913
|
+
if (await locator.count().catch(() => 0) > 0) {
|
|
914
|
+
await locator.click({ timeout: 5_000 }).catch(() => undefined)
|
|
915
|
+
return
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
})
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
/** Find the composer textarea (defensive selector list). */
|
|
922
|
+
private async composerLocator(): Promise<ReturnType<Page['locator']>> {
|
|
923
|
+
const selectors = [
|
|
924
|
+
'#chat-input',
|
|
925
|
+
'textarea[placeholder*="给 DeepSeek"]',
|
|
926
|
+
'textarea[placeholder*="发送消息"]',
|
|
927
|
+
'textarea[placeholder*="Send a message"]',
|
|
928
|
+
'textarea',
|
|
929
|
+
]
|
|
930
|
+
for (const selector of selectors) {
|
|
931
|
+
const locator = this.page!.locator(selector).first()
|
|
932
|
+
if (await locator.count().catch(() => 0) > 0) return locator
|
|
933
|
+
}
|
|
934
|
+
return this.page!.locator('textarea').first()
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* Upload local image files into the composer through the page's (usually
|
|
939
|
+
* hidden) file input. `setInputFiles` fires the input's change event, which
|
|
940
|
+
* is how DeepSeek picks up attachments without clicking its native dialog.
|
|
941
|
+
*/
|
|
942
|
+
private async attachImages(paths: string[]): Promise<{ ok: boolean; error?: string }> {
|
|
943
|
+
if (this.page === undefined) return { ok: false, error: '浏览器未启动' }
|
|
944
|
+
if (paths.length === 0) return { ok: true }
|
|
945
|
+
const selectors = [
|
|
946
|
+
'input[type="file"][accept*="image" i]',
|
|
947
|
+
'input[type="file"]',
|
|
948
|
+
]
|
|
949
|
+
for (const selector of selectors) {
|
|
950
|
+
const input = this.page.locator(selector).first()
|
|
951
|
+
if (await input.count().catch(() => 0) === 0) continue
|
|
952
|
+
try {
|
|
953
|
+
await input.setInputFiles(paths)
|
|
954
|
+
// Let the upload + preview render settle before submitting.
|
|
955
|
+
await this.page.waitForTimeout(1_000)
|
|
956
|
+
return { ok: true }
|
|
957
|
+
} catch (error) {
|
|
958
|
+
return { ok: false, error: `图片上传失败:${String(error)}` }
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
return { ok: false, error: '未找到图片上传入口(页面可能已改版或当前会话不支持图片)' }
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* Send a message through the real web page.
|
|
966
|
+
* @param text - message text.
|
|
967
|
+
* @param wait - when true (agent tools), resolve with the final reply after
|
|
968
|
+
* streaming completes; when false (GUI), resolve right after the message
|
|
969
|
+
* is submitted — the reply streams in the background into the transcript
|
|
970
|
+
* and the panel polls it live.
|
|
971
|
+
*/
|
|
972
|
+
send(text: string, wait = false, images?: string[]): Promise<SendResult> {
|
|
973
|
+
return this.queue.run(() => this.sendImpl(text, wait, images))
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
private async sendImpl(text: string, wait: boolean, images?: string[]): Promise<SendResult> {
|
|
977
|
+
this.setLastError(undefined)
|
|
978
|
+
if (this.page === undefined) {
|
|
979
|
+
try {
|
|
980
|
+
await this.ensureBrowser()
|
|
981
|
+
} catch (error) {
|
|
982
|
+
const message = String(error)
|
|
983
|
+
this.setLastError(message, 'NETWORK')
|
|
984
|
+
return { ok: false, error: message, code: 'NETWORK' }
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
const loggedIn = await this.isLoggedIn()
|
|
988
|
+
if (loggedIn !== true) {
|
|
989
|
+
const message = '尚未登录 DeepSeek 网页端。请在插件面板点击「打开登录窗口」,在弹出的浏览器中完成登录后重试。'
|
|
990
|
+
this.setLastError(message, 'NEED_LOGIN')
|
|
991
|
+
return { ok: false, error: message, code: 'NEED_LOGIN' }
|
|
992
|
+
}
|
|
993
|
+
try {
|
|
994
|
+
const chat = this.store.ensureActiveChat(this.deepThink ? 'deepseek-reasoner' : 'deepseek-chat')
|
|
995
|
+
const userMessage: WebChatMessage = {
|
|
996
|
+
id: randomUUID(), role: 'user', content: text, ts: Date.now(),
|
|
997
|
+
...(images !== undefined && images.length > 0 ? { attachments: images } : {}),
|
|
998
|
+
}
|
|
999
|
+
this.store.appendMessage(chat.id, userMessage)
|
|
1000
|
+
if (chat.title === '新的对话') {
|
|
1001
|
+
this.store.renameChat(chat.id, text.replace(/\s+/g, ' ').slice(0, 40))
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// Type into the real composer and submit with Enter.
|
|
1005
|
+
const page = this.page
|
|
1006
|
+
if (page === undefined) return { ok: false, error: '浏览器未启动' }
|
|
1007
|
+
const composer = await this.composerLocator()
|
|
1008
|
+
await composer.waitFor({ state: 'visible', timeout: 15_000 }).catch(() => undefined)
|
|
1009
|
+
await composer.click({ timeout: 5_000 }).catch(() => undefined)
|
|
1010
|
+
await composer.fill(text, { timeout: 10_000 }).catch(async () => {
|
|
1011
|
+
await composer.type(text, { delay: 5 })
|
|
1012
|
+
})
|
|
1013
|
+
// Attach any images before submitting (setInputFiles on the page's file input).
|
|
1014
|
+
if (images !== undefined && images.length > 0) {
|
|
1015
|
+
const attach = await this.attachImages(images)
|
|
1016
|
+
if (!attach.ok) {
|
|
1017
|
+
const message = attach.error ?? '图片上传失败'
|
|
1018
|
+
this.setLastError(message, 'NETWORK')
|
|
1019
|
+
return { ok: false, error: message, code: 'NETWORK' }
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
// Reset the stream capture so the reply loop only sees this request.
|
|
1023
|
+
await page.evaluate(() => {
|
|
1024
|
+
const w = window as unknown as { __wcStream?: StreamCapture }
|
|
1025
|
+
w.__wcStream = { text: '', done: false, started: false, status: 0, error: '' }
|
|
1026
|
+
}).catch(() => undefined)
|
|
1027
|
+
await page.keyboard.press('Enter')
|
|
1028
|
+
|
|
1029
|
+
const assistantId = randomUUID()
|
|
1030
|
+
if (!wait) {
|
|
1031
|
+
// Fire-and-forget for the GUI: the background loop streams into the
|
|
1032
|
+
// transcript; the panel polls /state and renders live.
|
|
1033
|
+
void this.streamReply(chat.id, assistantId)
|
|
1034
|
+
return { ok: true, chatId: chat.id }
|
|
1035
|
+
}
|
|
1036
|
+
const result = await this.streamReply(chat.id, assistantId)
|
|
1037
|
+
return result
|
|
1038
|
+
} catch (error) {
|
|
1039
|
+
const message = `发送失败:${String(error)}`
|
|
1040
|
+
this.setLastError(message)
|
|
1041
|
+
return { ok: false, error: message }
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* Background reply loop. Primary source is the teed SSE stream (raw model
|
|
1047
|
+
* markdown, no selectors); if the capture never installs, falls back to
|
|
1048
|
+
* scraping the rendered DOM. Writes the growing reply into the transcript
|
|
1049
|
+
* until the stream reports done/FINISHED or the timeout hits.
|
|
1050
|
+
*/
|
|
1051
|
+
private async streamReply(chatId: string, assistantId: string): Promise<SendResult> {
|
|
1052
|
+
if (this.page === undefined) return { ok: false, error: '浏览器未启动' }
|
|
1053
|
+
this.busy = true
|
|
1054
|
+
const started = Date.now()
|
|
1055
|
+
const timeout = this.config.replyTimeoutMs ?? DEFAULT_TIMEOUT_MS
|
|
1056
|
+
let replyMarkdown = ''
|
|
1057
|
+
let replyError: string | undefined
|
|
1058
|
+
let replyCode: WebChatErrorCode | undefined
|
|
1059
|
+
let domStable = 0
|
|
1060
|
+
let lastDom = ''
|
|
1061
|
+
|
|
1062
|
+
const readCapture = async (): Promise<StreamCapture | null> => {
|
|
1063
|
+
if (this.page === undefined) return null
|
|
1064
|
+
return this.page.evaluate(() => {
|
|
1065
|
+
const w = window as unknown as { __wcStream?: StreamCapture }
|
|
1066
|
+
return w.__wcStream ?? null
|
|
1067
|
+
}).catch(() => null)
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
const domSnapshot = async (): Promise<{ markdown: string }> => {
|
|
1071
|
+
const scraped = await this.scrapeConversation()
|
|
1072
|
+
const assistant = [...scraped].reverse().find(message => message.role === 'assistant')
|
|
1073
|
+
if (assistant === undefined) return { markdown: '' }
|
|
1074
|
+
const think = stripSearchTrace(assistant.parts.filter(part => part.kind === 'think').map(part => part.text).join('\n\n')).trim()
|
|
1075
|
+
const bodyHtml = assistant.parts.find(part => part.kind === 'body')?.markdown ?? ''
|
|
1076
|
+
const bodyMd = bodyHtml === '' ? '' : stripSearchTrace(serializeToMarkdown(parseMarkup(bodyHtml)))
|
|
1077
|
+
const thinkMd = think === '' ? '' : `<details><summary>思考过程</summary>\n\n${think}\n\n</details>`
|
|
1078
|
+
return { markdown: [thinkMd, bodyMd].filter(Boolean).join('\n\n') }
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
try {
|
|
1082
|
+
await this.page.waitForTimeout(700)
|
|
1083
|
+
let captureSeen = false
|
|
1084
|
+
let captureCompleted = false
|
|
1085
|
+
while (Date.now() - started < timeout) {
|
|
1086
|
+
const capture = await readCapture()
|
|
1087
|
+
if (capture !== null && capture.started) {
|
|
1088
|
+
captureSeen = true
|
|
1089
|
+
// Primary: parse the teed SSE stream.
|
|
1090
|
+
const parsed = parseStreamReply(capture.text)
|
|
1091
|
+
if (parsed.markdown !== '') {
|
|
1092
|
+
replyMarkdown = parsed.markdown
|
|
1093
|
+
this.store.upsertMessage(chatId, {
|
|
1094
|
+
id: assistantId, role: 'assistant', content: replyMarkdown, ts: Date.now(),
|
|
1095
|
+
streaming: !(capture.done || parsed.finished),
|
|
1096
|
+
})
|
|
1097
|
+
}
|
|
1098
|
+
if (capture.done || parsed.finished) {
|
|
1099
|
+
captureCompleted = true
|
|
1100
|
+
break
|
|
1101
|
+
}
|
|
1102
|
+
if (capture.error !== '') {
|
|
1103
|
+
replyError = capture.error
|
|
1104
|
+
replyCode = 'NETWORK'
|
|
1105
|
+
break
|
|
1106
|
+
}
|
|
1107
|
+
} else {
|
|
1108
|
+
// Fallback: scrape the rendered DOM until the capture produces data.
|
|
1109
|
+
const dom = await domSnapshot()
|
|
1110
|
+
if (dom.markdown !== '') {
|
|
1111
|
+
if (dom.markdown !== lastDom) {
|
|
1112
|
+
lastDom = dom.markdown
|
|
1113
|
+
domStable = 0
|
|
1114
|
+
replyMarkdown = dom.markdown
|
|
1115
|
+
} else {
|
|
1116
|
+
domStable += 1
|
|
1117
|
+
}
|
|
1118
|
+
this.store.upsertMessage(chatId, {
|
|
1119
|
+
id: assistantId, role: 'assistant', content: replyMarkdown, ts: Date.now(), streaming: true,
|
|
1120
|
+
})
|
|
1121
|
+
}
|
|
1122
|
+
if (replyMarkdown !== '' && domStable >= 3) break
|
|
1123
|
+
}
|
|
1124
|
+
await this.page.waitForTimeout(350)
|
|
1125
|
+
}
|
|
1126
|
+
if (replyMarkdown === '' && replyCode === undefined) {
|
|
1127
|
+
if (captureSeen && captureCompleted) {
|
|
1128
|
+
replyError = '页面协议疑似改版:已捕获到回复流但无法解析出内容,请升级 dsh-webchat 插件'
|
|
1129
|
+
replyCode = 'PAGE_CHANGED'
|
|
1130
|
+
} else {
|
|
1131
|
+
replyError = '等待回复超时(未捕获到网页回复流;可能未登录或页面结构已变化)'
|
|
1132
|
+
replyCode = 'TIMEOUT'
|
|
1133
|
+
}
|
|
1134
|
+
} else if (replyCode === undefined && Date.now() - started >= timeout) {
|
|
1135
|
+
replyError = '生成超时,已返回部分内容'
|
|
1136
|
+
replyCode = 'TIMEOUT'
|
|
1137
|
+
}
|
|
1138
|
+
this.store.upsertMessage(chatId, {
|
|
1139
|
+
id: assistantId, role: 'assistant', content: replyMarkdown, ts: Date.now(),
|
|
1140
|
+
streaming: false, error: replyError,
|
|
1141
|
+
})
|
|
1142
|
+
this.store.setStreaming(chatId, false)
|
|
1143
|
+
if (replyError !== undefined) this.setLastError(replyError, replyCode)
|
|
1144
|
+
return { ok: replyError === undefined, chatId, reply: replyMarkdown, error: replyError, code: replyCode }
|
|
1145
|
+
} catch (error) {
|
|
1146
|
+
const message = `生成过程中断:${String(error)}`
|
|
1147
|
+
this.setLastError(message)
|
|
1148
|
+
this.store.upsertMessage(chatId, {
|
|
1149
|
+
id: assistantId, role: 'assistant', content: replyMarkdown, ts: Date.now(),
|
|
1150
|
+
streaming: false, error: message,
|
|
1151
|
+
})
|
|
1152
|
+
this.store.setStreaming(chatId, false)
|
|
1153
|
+
return { ok: false, chatId, reply: replyMarkdown, error: message }
|
|
1154
|
+
} finally {
|
|
1155
|
+
this.busy = false
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/** Start a new chat on the web page (best effort) + a fresh local transcript. */
|
|
1160
|
+
async newChat(): Promise<{ ok: boolean; chatId?: string; error?: string }> {
|
|
1161
|
+
if (this.busy) return { ok: false, error: '正在生成回复,请先停止或等待完成' }
|
|
1162
|
+
return this.queue.run(async () => {
|
|
1163
|
+
try {
|
|
1164
|
+
await this.ensureBrowser()
|
|
1165
|
+
const chat = this.store.createChat(this.deepThink ? 'deepseek-reasoner' : 'deepseek-chat')
|
|
1166
|
+
if (this.page !== undefined) {
|
|
1167
|
+
const clickSelectors = ['button:has-text("新对话")', 'button:has-text("New chat")', '[class*="newChat"]']
|
|
1168
|
+
let clicked = false
|
|
1169
|
+
for (const selector of clickSelectors) {
|
|
1170
|
+
const locator = this.page.locator(selector).first()
|
|
1171
|
+
if (await locator.count().catch(() => 0) > 0) {
|
|
1172
|
+
await locator.click({ timeout: 5_000 }).catch(() => undefined)
|
|
1173
|
+
clicked = true
|
|
1174
|
+
break
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
if (!clicked) {
|
|
1178
|
+
await this.openDeepSeekPage()
|
|
1179
|
+
}
|
|
1180
|
+
await this.page.waitForTimeout(1_500)
|
|
1181
|
+
}
|
|
1182
|
+
return { ok: true, chatId: chat.id }
|
|
1183
|
+
} catch (error) {
|
|
1184
|
+
return { ok: false, error: String(error) }
|
|
1185
|
+
}
|
|
1186
|
+
})
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* Click a toggle on the page by label candidates (best effort — the DeepSeek
|
|
1191
|
+
* web UI has no stable contract, so a miss is not an error). The toggles are
|
|
1192
|
+
* `div.ds-toggle-button` elements (not `<button>`), so those selectors come
|
|
1193
|
+
* first; `<button>` variants remain as fallbacks for older page versions.
|
|
1194
|
+
* @returns true when a candidate was clicked.
|
|
1195
|
+
*/
|
|
1196
|
+
private async clickToggle(labels: string[]): Promise<boolean> {
|
|
1197
|
+
if (!this.isPageAlive()) return false
|
|
1198
|
+
const selectors: string[] = []
|
|
1199
|
+
for (const label of labels) {
|
|
1200
|
+
selectors.push(
|
|
1201
|
+
`div.ds-toggle-button:has-text("${label}")`,
|
|
1202
|
+
`[aria-pressed]:has-text("${label}")`,
|
|
1203
|
+
`button:has-text("${label}")`,
|
|
1204
|
+
`[aria-label*="${label}"]`,
|
|
1205
|
+
)
|
|
1206
|
+
}
|
|
1207
|
+
for (const selector of selectors) {
|
|
1208
|
+
const locator = this.page!.locator(selector).first()
|
|
1209
|
+
if (await locator.count().catch(() => 0) > 0) {
|
|
1210
|
+
await locator.click({ timeout: 5_000 }).catch(() => undefined)
|
|
1211
|
+
return true
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
return false
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
/** Toggle deep-think (R1) mode on the web page. */
|
|
1218
|
+
async setDeepThink(enabled: boolean): Promise<{ ok: boolean; error?: string }> {
|
|
1219
|
+
if (this.busy) return { ok: false, error: '正在生成回复,请先等待完成' }
|
|
1220
|
+
return this.queue.run(async () => {
|
|
1221
|
+
try {
|
|
1222
|
+
await this.ensureBrowser()
|
|
1223
|
+
const current = await this.readToggles()
|
|
1224
|
+
if (current.deepThink !== enabled) {
|
|
1225
|
+
await this.clickToggle(['深度思考', 'DeepThink', 'Deep Think'])
|
|
1226
|
+
}
|
|
1227
|
+
this.deepThink = enabled
|
|
1228
|
+
return { ok: true }
|
|
1229
|
+
} catch (error) {
|
|
1230
|
+
return { ok: false, error: String(error) }
|
|
1231
|
+
}
|
|
1232
|
+
})
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
/** Toggle internet search on the web page (web label: 智能搜索). */
|
|
1236
|
+
async setSearch(enabled: boolean): Promise<{ ok: boolean; error?: string }> {
|
|
1237
|
+
if (this.busy) return { ok: false, error: '正在生成回复,请先等待完成' }
|
|
1238
|
+
return this.queue.run(async () => {
|
|
1239
|
+
try {
|
|
1240
|
+
await this.ensureBrowser()
|
|
1241
|
+
const current = await this.readToggles()
|
|
1242
|
+
if (current.search !== enabled) {
|
|
1243
|
+
await this.clickToggle(['智能搜索', '联网搜索', 'Search'])
|
|
1244
|
+
}
|
|
1245
|
+
this.search = enabled
|
|
1246
|
+
return { ok: true }
|
|
1247
|
+
} catch (error) {
|
|
1248
|
+
return { ok: false, error: String(error) }
|
|
1249
|
+
}
|
|
1250
|
+
})
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/** Close the browser (releases the profile lock). */
|
|
1254
|
+
async disposeBrowser(): Promise<void> {
|
|
1255
|
+
try {
|
|
1256
|
+
await this.context?.close()
|
|
1257
|
+
} catch {
|
|
1258
|
+
// already closed
|
|
1259
|
+
}
|
|
1260
|
+
this.context = undefined
|
|
1261
|
+
this.page = undefined
|
|
1262
|
+
this.loginMode = false
|
|
1263
|
+
if (this.state !== 'error') this.setState('stopped')
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/** Engine snapshot for status routes and agent tools. */
|
|
1267
|
+
async status(): Promise<{
|
|
1268
|
+
engine: EngineState
|
|
1269
|
+
engineError?: string
|
|
1270
|
+
loggedIn: boolean | null
|
|
1271
|
+
pageUrl?: string
|
|
1272
|
+
deepThink: boolean
|
|
1273
|
+
search: boolean
|
|
1274
|
+
busy: boolean
|
|
1275
|
+
lastError?: string
|
|
1276
|
+
lastErrorCode?: WebChatErrorCode
|
|
1277
|
+
}> {
|
|
1278
|
+
// Self-heal: if the browser was up but the page died (e.g. the user closed
|
|
1279
|
+
// the window), relaunch so the panel reconnects. Explicit disposeBrowser()
|
|
1280
|
+
// leaves state 'stopped', which we leave alone until the next user action.
|
|
1281
|
+
if (this.state === 'ready' && !this.isPageAlive()) {
|
|
1282
|
+
await this.ensureBrowser().catch(() => undefined)
|
|
1283
|
+
}
|
|
1284
|
+
let loggedIn = await this.isLoggedIn()
|
|
1285
|
+
if (loggedIn === true) this.loggedInOnce = true
|
|
1286
|
+
else if (loggedIn === false) this.loggedInOnce = false
|
|
1287
|
+
else if (loggedIn === null && this.loggedInOnce) loggedIn = true
|
|
1288
|
+
const toggles = await this.readToggles()
|
|
1289
|
+
this.deepThink = toggles.deepThink
|
|
1290
|
+
this.search = toggles.search
|
|
1291
|
+
return {
|
|
1292
|
+
engine: this.state,
|
|
1293
|
+
engineError: this.engineError,
|
|
1294
|
+
loggedIn,
|
|
1295
|
+
pageUrl: this.pageUrl(),
|
|
1296
|
+
deepThink: this.deepThink,
|
|
1297
|
+
search: this.search,
|
|
1298
|
+
busy: this.busy,
|
|
1299
|
+
lastError: this.lastError,
|
|
1300
|
+
lastErrorCode: this.lastErrorCode,
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
/**
|
|
1306
|
+
* Minimal HTML fragment parser used to round-trip scraped `.ds-markdown`
|
|
1307
|
+
* innerHTML through htmlToMarkdown (the in-page evaluate returns HTML
|
|
1308
|
+
* strings; the converter consumes a light DOM-shaped object graph).
|
|
1309
|
+
*/
|
|
1310
|
+
function parseMarkup(html: string): MarkupNode {
|
|
1311
|
+
return new MarkupParser(html).parse()
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
export interface MarkupNode {
|
|
1315
|
+
readonly tagName?: string
|
|
1316
|
+
readonly nodeType: number
|
|
1317
|
+
readonly textContent?: string
|
|
1318
|
+
readonly children: MarkupNode[]
|
|
1319
|
+
readonly attributes: Record<string, string | undefined>
|
|
1320
|
+
readonly parent?: MarkupNode
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
/** Tiny HTML tokenizer → light DOM graph (sufficient for DeepSeek's markdown HTML). */
|
|
1324
|
+
class MarkupParser {
|
|
1325
|
+
private readonly tokens: string[]
|
|
1326
|
+
private index = 0
|
|
1327
|
+
|
|
1328
|
+
constructor(html: string) {
|
|
1329
|
+
// Tokenize into tags and text (naive but adequate: DeepSeek renders
|
|
1330
|
+
// well-formed HTML with no unescaped '<' in text).
|
|
1331
|
+
this.tokens = html.split(/(<[^>]+>)/).filter(token => token !== '')
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
parse(): MarkupNode {
|
|
1335
|
+
const root = this.parseChildren(undefined)
|
|
1336
|
+
return root
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
private parseChildren(parent: MarkupNode | undefined): MarkupNode {
|
|
1340
|
+
const node: MarkupNode = { nodeType: 1, children: [], attributes: {}, parent }
|
|
1341
|
+
while (this.index < this.tokens.length) {
|
|
1342
|
+
const token = this.tokens[this.index]
|
|
1343
|
+
if (!token.startsWith('<')) {
|
|
1344
|
+
node.children.push({ nodeType: 3, textContent: token, children: [], attributes: {}, parent: node })
|
|
1345
|
+
this.index++
|
|
1346
|
+
continue
|
|
1347
|
+
}
|
|
1348
|
+
const close = /^<\/([a-zA-Z0-9]+)>$/.exec(token)
|
|
1349
|
+
if (close !== null) {
|
|
1350
|
+
this.index++
|
|
1351
|
+
if (close[1].toLowerCase() === (node.tagName ?? '').toLowerCase()) return node
|
|
1352
|
+
continue // mismatched close: ignore
|
|
1353
|
+
}
|
|
1354
|
+
const open = /^<([a-zA-Z0-9]+)((?:\s+[a-zA-Z0-9-]+(?:=(?:"[^"]*"|'[^']*'|[^\s>]*))?)*)\s*(\/?)>$/.exec(token)
|
|
1355
|
+
if (open === null) {
|
|
1356
|
+
this.index++
|
|
1357
|
+
continue
|
|
1358
|
+
}
|
|
1359
|
+
const [, rawTag, attrsRaw] = open
|
|
1360
|
+
const tag = rawTag.toLowerCase()
|
|
1361
|
+
const attributes: Record<string, string | undefined> = {}
|
|
1362
|
+
if (attrsRaw !== undefined) {
|
|
1363
|
+
const attrRe = /([a-zA-Z0-9-]+)(?:=("[^"]*"|'[^']*'|[^\s>]*))?/g
|
|
1364
|
+
let match: RegExpExecArray | null
|
|
1365
|
+
while ((match = attrRe.exec(attrsRaw)) !== null) {
|
|
1366
|
+
const value = match[2] === undefined ? undefined : match[2].replace(/^["']|["']$/g, '')
|
|
1367
|
+
attributes[match[1]] = value
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
this.index++
|
|
1371
|
+
const element: MarkupNode = { tagName: tag, nodeType: 1, children: [], attributes, parent }
|
|
1372
|
+
if (!open[3].endsWith('/')) {
|
|
1373
|
+
const child = this.parseChildren(element)
|
|
1374
|
+
for (const grandchild of child.children) element.children.push(grandchild)
|
|
1375
|
+
}
|
|
1376
|
+
node.children.push(element)
|
|
1377
|
+
}
|
|
1378
|
+
return node
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/** Convert a scraped markdown-HTML string to markdown (used by send). */
|
|
1383
|
+
export function scrapedHtmlToMarkdown(html: string): string {
|
|
1384
|
+
return serializeToMarkdown(parseMarkup(html))
|
|
1385
|
+
}
|