@jaychang1989/dsh-webchat 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +10 -3
- package/README.md +44 -40
- package/README.zh.md +43 -44
- package/cordis.patch.yml +8 -7
- package/lib/client.js +402 -1693
- package/lib/index.js +246 -3884
- package/package.json +3 -35
- package/lib/client.js.map +0 -1
- package/lib/types/client/api.d.ts +0 -74
- package/lib/types/client/controller.d.ts +0 -19
- package/lib/types/client/index.d.ts +0 -30
- package/lib/types/client/locales.d.ts +0 -169
- package/lib/types/client/mount.d.ts +0 -23
- package/lib/types/client/panel/Markdown.d.ts +0 -15
- package/lib/types/client/panel/WebChatPanel.d.ts +0 -28
- package/lib/types/client/sidebar-entry-core.d.ts +0 -60
- package/lib/types/client/sidebar-entry.d.ts +0 -22
- package/lib/types/engine/engine.d.ts +0 -247
- package/lib/types/engine/html-md.d.ts +0 -23
- package/lib/types/index.d.ts +0 -63
- package/lib/types/protocol.d.ts +0 -107
- package/lib/types/routes.d.ts +0 -22
- package/lib/types/store.d.ts +0 -69
- package/lib/types/tools.d.ts +0 -27
- package/lib/types/transfer.d.ts +0 -138
- package/src/client/api.ts +0 -90
- package/src/client/controller.ts +0 -43
- package/src/client/css-modules.d.ts +0 -5
- package/src/client/index.ts +0 -75
- package/src/client/locales.ts +0 -172
- package/src/client/mount.tsx +0 -125
- package/src/client/panel/Markdown.tsx +0 -251
- package/src/client/panel/WebChatPanel.tsx +0 -760
- package/src/client/panel/panel.module.css +0 -936
- package/src/client/sidebar-entry-core.ts +0 -207
- package/src/client/sidebar-entry.ts +0 -49
- package/src/engine/engine.ts +0 -1385
- package/src/engine/html-md.ts +0 -126
- package/src/index.ts +0 -212
- package/src/protocol.ts +0 -116
- package/src/routes.ts +0 -307
- package/src/store.ts +0 -217
- package/src/tools.ts +0 -257
- package/src/transfer.ts +0 -602
package/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
|
-
}
|