@jaychang1989/dsh-webchat 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/NOTICE +10 -3
  2. package/README.md +44 -40
  3. package/README.zh.md +43 -44
  4. package/cordis.patch.yml +8 -7
  5. package/lib/client.js +402 -1693
  6. package/lib/index.js +246 -3884
  7. package/package.json +3 -35
  8. package/lib/client.js.map +0 -1
  9. package/lib/types/client/api.d.ts +0 -74
  10. package/lib/types/client/controller.d.ts +0 -19
  11. package/lib/types/client/index.d.ts +0 -30
  12. package/lib/types/client/locales.d.ts +0 -169
  13. package/lib/types/client/mount.d.ts +0 -23
  14. package/lib/types/client/panel/Markdown.d.ts +0 -15
  15. package/lib/types/client/panel/WebChatPanel.d.ts +0 -28
  16. package/lib/types/client/sidebar-entry-core.d.ts +0 -60
  17. package/lib/types/client/sidebar-entry.d.ts +0 -22
  18. package/lib/types/engine/engine.d.ts +0 -247
  19. package/lib/types/engine/html-md.d.ts +0 -23
  20. package/lib/types/index.d.ts +0 -63
  21. package/lib/types/protocol.d.ts +0 -107
  22. package/lib/types/routes.d.ts +0 -22
  23. package/lib/types/store.d.ts +0 -69
  24. package/lib/types/tools.d.ts +0 -27
  25. package/lib/types/transfer.d.ts +0 -138
  26. package/src/client/api.ts +0 -90
  27. package/src/client/controller.ts +0 -43
  28. package/src/client/css-modules.d.ts +0 -5
  29. package/src/client/index.ts +0 -75
  30. package/src/client/locales.ts +0 -172
  31. package/src/client/mount.tsx +0 -125
  32. package/src/client/panel/Markdown.tsx +0 -251
  33. package/src/client/panel/WebChatPanel.tsx +0 -760
  34. package/src/client/panel/panel.module.css +0 -936
  35. package/src/client/sidebar-entry-core.ts +0 -207
  36. package/src/client/sidebar-entry.ts +0 -49
  37. package/src/engine/engine.ts +0 -1385
  38. package/src/engine/html-md.ts +0 -126
  39. package/src/index.ts +0 -212
  40. package/src/protocol.ts +0 -116
  41. package/src/routes.ts +0 -307
  42. package/src/store.ts +0 -217
  43. package/src/tools.ts +0 -257
  44. package/src/transfer.ts +0 -602
@@ -1,1385 +0,0 @@
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
- }