@jaychang1989/dsh-webchat 0.3.0 → 0.4.1

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 -39
  3. package/README.zh.md +43 -43
  4. package/cordis.patch.yml +8 -7
  5. package/lib/client.js +203 -1703
  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
package/src/routes.ts DELETED
@@ -1,307 +0,0 @@
1
- /**
2
- * The /api/dsh-webchat route family: engine state, browser login control,
3
- * chat operations (new chat / send / stop / switch model), transcript
4
- * export, and the harness transfer that seeds a new session with a web
5
- * transcript. Every route carries the same loopback-only trust fence the
6
- * dsh-ssh plugin uses — these endpoints drive a browser and create sessions,
7
- * so LAN-exposed deployments must not serve them.
8
- */
9
-
10
- import type { IncomingMessage, ServerResponse } from 'node:http'
11
- import type { Context } from '@deepseek-ai/cordis'
12
- import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
13
- import type { DeepSeekWebEngine } from './engine/engine.ts'
14
- import type { TranscriptStore } from './store.ts'
15
- import type { WebChatTranscript } from './protocol.ts'
16
- import { exportTranscriptFile, transferToHarnessSession } from './transfer.ts'
17
- import type { DistillConfig } from './transfer.ts'
18
-
19
- /** Cap on JSON request bodies (chat ops are small). */
20
- const MAX_JSON_BODY_BYTES = 64 * 1024
21
-
22
- /** Loopback-only trust fence (mirrors dsh-ssh). */
23
- function isLoopbackRequest(req: IncomingMessage): boolean {
24
- const host = req.headers.host ?? ''
25
- const address = req.socket.remoteAddress ?? ''
26
- const loopbackHost = host.startsWith('127.0.0.1') || host.startsWith('localhost') || host.startsWith('[::1]')
27
- const loopbackAddr = address === '127.0.0.1' || address === '::1' || address === '::ffff:127.0.0.1' || address === undefined
28
- return loopbackHost && loopbackAddr
29
- }
30
-
31
- /** One JSON response. */
32
- function writeJson(res: ServerResponse, status: number, body: unknown): void {
33
- const payload = JSON.stringify(body)
34
- res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'referrer-policy': 'no-referrer' })
35
- res.end(payload)
36
- }
37
-
38
- /** Read a JSON request body. */
39
- async function readJsonBody(req: IncomingMessage): Promise<Record<string, unknown> | undefined> {
40
- const chunks: Buffer[] = []
41
- let size = 0
42
- for await (const chunk of req) {
43
- const buffer = chunk as Buffer
44
- size += buffer.length
45
- if (size > MAX_JSON_BODY_BYTES) return undefined
46
- chunks.push(buffer)
47
- }
48
- try {
49
- const parsed: unknown = JSON.parse(Buffer.concat(chunks).toString('utf8'))
50
- return typeof parsed === 'object' && parsed !== null ? parsed as Record<string, unknown> : undefined
51
- } catch {
52
- return undefined
53
- }
54
- }
55
-
56
- function stringField(body: Record<string, unknown> | undefined, name: string): string | undefined {
57
- const value = body?.[name]
58
- return typeof value === 'string' && value !== '' ? value : undefined
59
- }
60
-
61
- /** Route family dependencies. */
62
- export interface WebChatRoutesDeps {
63
- ctx: Context
64
- engine: DeepSeekWebEngine
65
- store: TranscriptStore
66
- distill: DistillConfig
67
- }
68
-
69
- /** Build every /api/dsh-webchat route. */
70
- export function makeRoutes(deps: WebChatRoutesDeps): WebRoute[] {
71
- const { ctx, engine, store, distill } = deps
72
-
73
- const guard = (req: IncomingMessage, res: ServerResponse): boolean => {
74
- if (isLoopbackRequest(req)) return true
75
- writeJson(res, 403, { ok: false, error: 'loopback only' })
76
- return false
77
- }
78
-
79
- const stateView = async (): Promise<Record<string, unknown>> => {
80
- const status = await engine.status()
81
- return {
82
- ok: true,
83
- engine: status.engine,
84
- engineError: status.engineError,
85
- loggedIn: status.loggedIn,
86
- pageUrl: status.pageUrl,
87
- deepThink: status.deepThink,
88
- search: status.search,
89
- busy: status.busy,
90
- lastError: status.lastError,
91
- lastErrorCode: status.lastErrorCode,
92
- activeChatId: store.activeChat()?.id,
93
- chats: store.list(),
94
- }
95
- }
96
-
97
- return [
98
- {
99
- kind: 'exact',
100
- path: '/api/dsh-webchat/state',
101
- handler: async (req, res) => {
102
- if (!guard(req, res)) return
103
- writeJson(res, 200, await stateView())
104
- },
105
- },
106
- {
107
- kind: 'exact',
108
- path: '/api/dsh-webchat/open-login',
109
- handler: async (req, res) => {
110
- if (!guard(req, res)) return
111
- const result = await engine.openLoginWindow()
112
- writeJson(res, result.ok ? 200 : 500, { ok: result.ok, error: result.error })
113
- },
114
- },
115
- {
116
- kind: 'exact',
117
- path: '/api/dsh-webchat/close-browser',
118
- handler: async (req, res) => {
119
- if (!guard(req, res)) return
120
- await engine.disposeBrowser()
121
- writeJson(res, 200, { ok: true })
122
- },
123
- },
124
- {
125
- kind: 'exact',
126
- path: '/api/dsh-webchat/new-chat',
127
- handler: async (req, res) => {
128
- if (!guard(req, res)) return
129
- const result = await engine.newChat()
130
- writeJson(res, result.ok ? 200 : 500, result)
131
- },
132
- },
133
- {
134
- kind: 'exact',
135
- path: '/api/dsh-webchat/send',
136
- handler: async (req, res) => {
137
- if (!guard(req, res)) return
138
- const body = await readJsonBody(req)
139
- const text = stringField(body, 'text')
140
- if (text === undefined) {
141
- writeJson(res, 400, { ok: false, error: '缺少 text 字段' })
142
- return
143
- }
144
- // GUI sends resolve immediately; the reply streams in the background
145
- // into the transcript and the panel polls /state for live updates.
146
- const images = Array.isArray(body?.['images'])
147
- ? (body['images'] as unknown[]).filter(value => typeof value === 'string').map(value => value as string)
148
- : undefined
149
- const result = await engine.send(text, false, images)
150
- writeJson(res, result.ok ? 200 : 500, result)
151
- },
152
- },
153
- {
154
- kind: 'exact',
155
- path: '/api/dsh-webchat/stop',
156
- handler: async (req, res) => {
157
- if (!guard(req, res)) return
158
- await engine.stop()
159
- writeJson(res, 200, { ok: true })
160
- },
161
- },
162
- {
163
- kind: 'exact',
164
- path: '/api/dsh-webchat/deep-think',
165
- handler: async (req, res) => {
166
- if (!guard(req, res)) return
167
- const body = await readJsonBody(req)
168
- const enabled = typeof body?.['enabled'] === 'boolean' ? body['enabled'] : undefined
169
- if (enabled === undefined) {
170
- writeJson(res, 400, { ok: false, error: '缺少 enabled 字段' })
171
- return
172
- }
173
- const result = await engine.setDeepThink(enabled)
174
- writeJson(res, result.ok ? 200 : 500, result)
175
- },
176
- },
177
- {
178
- kind: 'exact',
179
- path: '/api/dsh-webchat/search',
180
- handler: async (req, res) => {
181
- if (!guard(req, res)) return
182
- const body = await readJsonBody(req)
183
- const enabled = typeof body?.['enabled'] === 'boolean' ? body['enabled'] : undefined
184
- if (enabled === undefined) {
185
- writeJson(res, 400, { ok: false, error: '缺少 enabled 字段' })
186
- return
187
- }
188
- const result = await engine.setSearch(enabled)
189
- writeJson(res, result.ok ? 200 : 500, result)
190
- },
191
- },
192
- {
193
- kind: 'exact',
194
- path: '/api/dsh-webchat/transfer',
195
- handler: async (req, res) => {
196
- if (!guard(req, res)) return
197
- const body = await readJsonBody(req)
198
- const chatId = stringField(body, 'chatId') ?? store.activeChat()?.id
199
- const cwd = stringField(body, 'cwd')
200
- const workspaceId = stringField(body, 'workspaceId')
201
- const targetSessionId = stringField(body, 'targetSessionId')
202
- const mode = body?.['mode'] === 'raw' ? 'raw' : body?.['mode'] === 'distill' ? 'distill' : undefined
203
- const transcript: WebChatTranscript | undefined = chatId === undefined ? undefined : store.getChat(chatId)
204
- if (transcript === undefined) {
205
- writeJson(res, 404, { ok: false, error: '找不到该对话记录' })
206
- return
207
- }
208
- try {
209
- const workspace = workspaceId === undefined ? undefined : { workspaceId }
210
- const { sessionId, distilled, attached, workspaceId: attachedWorkspaceId } = await transferToHarnessSession(ctx, { transcript, cwd, workspace, targetSessionId }, distill, mode)
211
- writeJson(res, 200, { ok: true, sessionId, distilled, attached, continued: targetSessionId !== undefined, workspaceId: attachedWorkspaceId })
212
- } catch (error) {
213
- writeJson(res, 500, { ok: false, error: String(error) })
214
- }
215
- },
216
- },
217
- {
218
- kind: 'exact',
219
- path: '/api/dsh-webchat/export',
220
- handler: async (req, res) => {
221
- if (!guard(req, res)) return
222
- const body = await readJsonBody(req)
223
- const chatId = stringField(body, 'chatId') ?? store.activeChat()?.id
224
- const cwd = stringField(body, 'cwd')
225
- const transcript: WebChatTranscript | undefined = chatId === undefined ? undefined : store.getChat(chatId)
226
- if (transcript === undefined) {
227
- writeJson(res, 404, { ok: false, error: '找不到该对话记录' })
228
- return
229
- }
230
- try {
231
- const { filePath } = exportTranscriptFile({ transcript, cwd })
232
- writeJson(res, 200, { ok: true, filePath })
233
- } catch (error) {
234
- writeJson(res, 500, { ok: false, error: String(error) })
235
- }
236
- },
237
- },
238
- {
239
- kind: 'exact',
240
- path: '/api/dsh-webchat/web-chats',
241
- handler: async (req, res) => {
242
- if (!guard(req, res)) return
243
- const web = await engine.listWebConversations()
244
- const localTitles = new Set(store.titles())
245
- const missing = web.filter(item => !localTitles.has(item.title)).map(item => item.title)
246
- writeJson(res, 200, { ok: true, web, missing })
247
- },
248
- },
249
- {
250
- kind: 'exact',
251
- path: '/api/dsh-webchat/recover',
252
- handler: async (req, res) => {
253
- if (!guard(req, res)) return
254
- const body = await readJsonBody(req)
255
- const title = stringField(body, 'title')
256
- if (title === undefined) {
257
- writeJson(res, 400, { ok: false, error: '缺少 title 字段' })
258
- return
259
- }
260
- const result = await engine.recoverWebConversation(title)
261
- writeJson(res, result.ok ? 200 : 500, result)
262
- },
263
- },
264
- {
265
- kind: 'exact',
266
- path: '/api/dsh-webchat/rename',
267
- handler: async (req, res) => {
268
- if (!guard(req, res)) return
269
- const body = await readJsonBody(req)
270
- const chatId = stringField(body, 'chatId')
271
- const title = stringField(body, 'title')
272
- if (chatId === undefined || title === undefined) {
273
- writeJson(res, 400, { ok: false, error: '缺少 chatId 或 title 字段' })
274
- return
275
- }
276
- const renamed = store.renameChat(chatId, title)
277
- if (renamed === undefined) writeJson(res, 404, { ok: false, error: '找不到该对话记录' })
278
- else writeJson(res, 200, { ok: true })
279
- },
280
- },
281
- {
282
- kind: 'exact',
283
- path: '/api/dsh-webchat/delete',
284
- handler: async (req, res) => {
285
- if (!guard(req, res)) return
286
- const body = await readJsonBody(req)
287
- const chatId = stringField(body, 'chatId')
288
- if (chatId === undefined) {
289
- writeJson(res, 400, { ok: false, error: '缺少 chatId 字段' })
290
- return
291
- }
292
- const deleted = store.deleteChat(chatId)
293
- if (!deleted) writeJson(res, 404, { ok: false, error: '找不到该对话记录' })
294
- else writeJson(res, 200, { ok: true })
295
- },
296
- },
297
- {
298
- kind: 'exact',
299
- path: '/api/dsh-webchat/clear',
300
- handler: async (req, res) => {
301
- if (!guard(req, res)) return
302
- const count = store.clearAllChats()
303
- writeJson(res, 200, { ok: true, count })
304
- },
305
- },
306
- ]
307
- }
package/src/store.ts DELETED
@@ -1,217 +0,0 @@
1
- /**
2
- * Local transcript store. Chats are persisted as one JSON file under the
3
- * plugin data dir (~/.dsh/dsh-webchat/transcripts.json by default) so web
4
- * conversations survive restarts and can be transferred into harness mode
5
- * at any time. Atomic writes (tmp + rename) keep a crash from corrupting
6
- * history.
7
- */
8
-
9
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
10
- import { dirname, join } from 'node:path'
11
- import { randomUUID } from 'node:crypto'
12
- import type { WebChatMessage, WebChatTranscript } from './protocol.ts'
13
-
14
- /** Default plugin data directory (tests inject a sandbox root). */
15
- export function defaultDataDir(): string {
16
- const home = process.env.DSH_HOME ?? process.env.HOME ?? '.'
17
- return join(home, '.dsh', 'dsh-webchat')
18
- }
19
-
20
- export interface TranscriptStoreOptions {
21
- /** Root data dir; the transcripts file lives directly under it. */
22
- dataDir?: string
23
- }
24
-
25
- interface StoreFile {
26
- version: 1
27
- activeChatId?: string
28
- chats: WebChatTranscript[]
29
- }
30
-
31
- /**
32
- * JSON-file transcript store. All mutations are synchronous and persisted
33
- * immediately (chats are small); the engine and routes use this single
34
- * instance so GUI and agent tools always see the same history.
35
- */
36
- export class TranscriptStore {
37
- readonly dataDir: string
38
- private readonly file: string
39
- private chats: WebChatTranscript[]
40
- private activeChatId: string | undefined
41
-
42
- constructor(options: TranscriptStoreOptions = {}) {
43
- this.dataDir = options.dataDir ?? defaultDataDir()
44
- this.file = join(this.dataDir, 'transcripts.json')
45
- const loaded = this.read()
46
- this.chats = loaded.chats
47
- this.activeChatId = loaded.activeChatId
48
- if (this.activeChatId !== undefined && !this.chats.some(chat => chat.id === this.activeChatId)) {
49
- this.activeChatId = this.chats.at(-1)?.id
50
- }
51
- }
52
-
53
- private read(): StoreFile {
54
- try {
55
- const parsed = JSON.parse(readFileSync(this.file, 'utf8')) as Partial<StoreFile>
56
- const chats = Array.isArray(parsed.chats) ? parsed.chats : []
57
- return {
58
- version: 1,
59
- activeChatId: typeof parsed.activeChatId === 'string' ? parsed.activeChatId : undefined,
60
- chats: chats.filter(chat => typeof chat?.id === 'string' && Array.isArray(chat.messages)),
61
- }
62
- } catch {
63
- return { version: 1, chats: [] }
64
- }
65
- }
66
-
67
- private persist(): void {
68
- mkdirSync(this.dataDir, { recursive: true, mode: 0o700 })
69
- const payload: StoreFile = { version: 1, activeChatId: this.activeChatId, chats: this.chats }
70
- const tmp = `${this.file}.tmp`
71
- writeFileSync(tmp, JSON.stringify(payload, null, 2), { mode: 0o600 })
72
- renameSync(tmp, this.file)
73
- }
74
-
75
- /** Create a fresh chat and make it active. */
76
- createChat(model: string): WebChatTranscript {
77
- const now = Date.now()
78
- const chat: WebChatTranscript = {
79
- id: `chat-${now.toString(36)}-${randomUUID().slice(0, 6)}`,
80
- title: '新的对话',
81
- createdAt: now,
82
- updatedAt: now,
83
- model,
84
- messages: [],
85
- streaming: false,
86
- }
87
- this.chats.unshift(chat)
88
- this.activeChatId = chat.id
89
- this.persist()
90
- return chat
91
- }
92
-
93
- /** All chats, newest first. */
94
- list(): WebChatTranscript[] {
95
- return [...this.chats]
96
- }
97
-
98
- /** Local transcript titles (for matching against the web sidebar). */
99
- titles(): string[] {
100
- return this.chats.map(chat => chat.title)
101
- }
102
-
103
- /**
104
- * Import a recovered web conversation as a local transcript. Dedups by exact
105
- * title (web conversations with the same title map to the existing chat), so
106
- * re-syncing is idempotent. Returns the chat plus whether it was newly created.
107
- */
108
- importTranscript(input: { title: string; model: string; messages: WebChatMessage[] }): { chat: WebChatTranscript; created: boolean } {
109
- const cleanTitle = input.title.trim().replace(/\s+/g, ' ').slice(0, 80) || '新的对话'
110
- const existing = this.chats.find(chat => chat.title === cleanTitle)
111
- if (existing !== undefined) {
112
- this.activeChatId = existing.id
113
- return { chat: existing, created: false }
114
- }
115
- const now = Date.now()
116
- const chat: WebChatTranscript = {
117
- id: `chat-${now.toString(36)}-${randomUUID().slice(0, 6)}`,
118
- title: cleanTitle,
119
- createdAt: now,
120
- updatedAt: now,
121
- model: input.model,
122
- messages: input.messages,
123
- streaming: false,
124
- }
125
- this.chats.unshift(chat)
126
- this.activeChatId = chat.id
127
- this.persist()
128
- return { chat, created: true }
129
- }
130
-
131
- /** The active chat, or undefined when none exists yet. */
132
- activeChat(): WebChatTranscript | undefined {
133
- if (this.activeChatId === undefined) return undefined
134
- return this.chats.find(chat => chat.id === this.activeChatId)
135
- }
136
-
137
- /** Read one chat by id. */
138
- getChat(id: string): WebChatTranscript | undefined {
139
- return this.chats.find(chat => chat.id === id)
140
- }
141
-
142
- /** Pick the active chat, creating one if none exists. */
143
- ensureActiveChat(model: string): WebChatTranscript {
144
- return this.activeChat() ?? this.createChat(model)
145
- }
146
-
147
- /** Set which chat is active. */
148
- setActiveChat(id: string): boolean {
149
- if (!this.chats.some(chat => chat.id === id)) return false
150
- this.activeChatId = id
151
- this.persist()
152
- return true
153
- }
154
-
155
- /** Mutate the active (or named) chat and persist. */
156
- private update(id: string, mutate: (chat: WebChatTranscript) => void): WebChatTranscript | undefined {
157
- const chat = this.chats.find(candidate => candidate.id === id)
158
- if (chat === undefined) return undefined
159
- mutate(chat)
160
- chat.updatedAt = Date.now()
161
- this.persist()
162
- return chat
163
- }
164
-
165
- /** Append a message to a chat. */
166
- appendMessage(id: string, message: WebChatMessage): WebChatTranscript | undefined {
167
- return this.update(id, chat => {
168
- chat.messages.push(message)
169
- if (message.role === 'assistant') chat.streaming = message.streaming ?? false
170
- })
171
- }
172
-
173
- /** Replace (or insert) one message by id — used for streaming updates. */
174
- upsertMessage(id: string, message: WebChatMessage): WebChatTranscript | undefined {
175
- return this.update(id, chat => {
176
- const index = chat.messages.findIndex(candidate => candidate.id === message.id)
177
- if (index >= 0) chat.messages[index] = message
178
- else chat.messages.push(message)
179
- if (message.role === 'assistant') chat.streaming = message.streaming ?? false
180
- })
181
- }
182
-
183
- /** Mark the chat's streaming flag (assistant reply started/stopped). */
184
- setStreaming(id: string, streaming: boolean, model?: string): WebChatTranscript | undefined {
185
- return this.update(id, chat => {
186
- chat.streaming = streaming
187
- if (model !== undefined) chat.model = model
188
- })
189
- }
190
-
191
- /** Rename a chat (used to pin a meaningful title after the first exchange). */
192
- renameChat(id: string, title: string): WebChatTranscript | undefined {
193
- const clean = title.trim().replace(/\s+/g, ' ').slice(0, 80)
194
- if (clean === '') return undefined
195
- return this.update(id, chat => { chat.title = clean })
196
- }
197
-
198
- /** Delete one chat; a deleted active chat falls back to the newest remaining. */
199
- deleteChat(id: string): boolean {
200
- const before = this.chats.length
201
- this.chats = this.chats.filter(chat => chat.id !== id)
202
- if (this.chats.length === before) return false
203
- if (this.activeChatId === id) this.activeChatId = this.chats.at(0)?.id
204
- this.persist()
205
- return true
206
- }
207
-
208
- /** Delete every chat; returns the number removed. */
209
- clearAllChats(): number {
210
- const count = this.chats.length
211
- if (count === 0) return 0
212
- this.chats = []
213
- this.activeChatId = undefined
214
- this.persist()
215
- return count
216
- }
217
- }