@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
package/src/transfer.ts DELETED
@@ -1,602 +0,0 @@
1
- /**
2
- * Harness-mode transfer: turn a web-chat transcript into development context.
3
- *
4
- * This is the "Continue in Codex" / ChatGPT-mode analog, and like Codex it is
5
- * a CONTEXT HANDOFF rather than a raw replay: the exploration-phase web
6
- * conversation is distilled (via the harness LLM) into an executable task
7
- * brief — the execution-phase state representation the agent actually needs —
8
- * and that brief seeds a fresh harness session. The raw transcript is kept as
9
- * the fallback when distillation is unavailable.
10
- *
11
- * Two targets:
12
- * - new harness session — a COLD persisted session seeded with the distilled
13
- * brief (or raw transcript), so it shows up in the GUI list and resumes;
14
- * - workspace file — the raw transcript rendered to markdown in the target
15
- * project directory, so any agent can read it with file tools.
16
- *
17
- * Session creation writes directly through the session-persistence backend
18
- * (`sessionPersistence.create` → write handle → `append` + `flush`), NOT
19
- * `ctx.sessions.create()`: the store's `create` produces a LIVE session owned
20
- * by the calling fiber, which the GUI then refuses to resume ("cannot prepare …
21
- * while it is live"). A cold persisted session is exactly what the GUI's resume
22
- * path expects.
23
- */
24
-
25
- import { mkdirSync, writeFileSync } from 'node:fs'
26
- import { realpath } from 'node:fs/promises'
27
- import { basename, join } from 'node:path'
28
- import { randomUUID } from 'node:crypto'
29
- import type { Context } from '@deepseek-ai/cordis'
30
- import { BlockAssembler, createUserMessage } from '@deepseek-ai/dsh-llm'
31
- import type { LlmRuntime, MessageId } from '@deepseek-ai/dsh-llm'
32
- import { SessionId, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session'
33
- import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
34
- import type { Workspace, WorkspaceId, WorkspaceRegistry } from '@deepseek-ai/dsh-workspace'
35
- import type { TransferMode, WebChatMessage, WebChatTranscript } from './protocol.ts'
36
-
37
- /**
38
- * Minimal seam of the dsh ≥ 0.2 session-persistence backend. Storage is
39
- * addressed through one handle per session (`create` / `open`) and appends go
40
- * through that handle; the dsh 0.1.x service-level `load(id)` /
41
- * `append(id, events)` pair is gone. Declared structurally here rather than
42
- * imported so the plugin keeps no hard type dependency on the backend package.
43
- */
44
- interface SessionPersistenceHandle {
45
- /** Read a contiguous slice of the stored log (defaults: whole log). */
46
- read(offset?: number, length?: number): Promise<{ events: SessionEvent[] }>
47
- /** Durably append one contiguous batch. */
48
- append(events: SessionEvent[]): Promise<void>
49
- /** Durability barrier; optional on backends that only flush service-wide. */
50
- flush?(): Promise<void> | void
51
- /** Release ownership; a write handle drains its buffer before releasing. */
52
- close?(): Promise<void> | void
53
- }
54
-
55
- /** The service half of the same seam. */
56
- interface SessionPersistenceSeam {
57
- create(header: SessionHeader): Promise<SessionPersistenceHandle>
58
- open(id: string, access: 'read' | 'write'): Promise<SessionPersistenceHandle>
59
- }
60
-
61
- /** Read the session-persistence seam without a hard service dependency. */
62
- function sessionPersistenceOf(ctx: Context): SessionPersistenceSeam | undefined {
63
- return ctx.get('sessionPersistence') as SessionPersistenceSeam | undefined
64
- }
65
-
66
- /** Role label used in rendered transcripts. */
67
- const ROLE_LABEL: Record<'user' | 'assistant', string> = { user: '用户', assistant: 'DeepSeek(网页端)' }
68
-
69
- /** Remove the collapsible R1 reasoning block(s) from reply markdown. */
70
- function stripThinking(markdown: string): string {
71
- return markdown
72
- .replace(/<details>\s*<summary>.*?<\/summary>[\s\S]*?<\/details>/g, '')
73
- .replace(/\n{3,}/g, '\n\n')
74
- .trim()
75
- }
76
-
77
- /** Render the message list of a transcript (no header) to markdown. */
78
- export function renderMessagesMarkdown(messages: WebChatMessage[], options?: { excludeThinking?: boolean }): string {
79
- const excludeThinking = options?.excludeThinking ?? false
80
- const lines: string[] = []
81
- for (const message of messages) {
82
- if (message.role === 'assistant' && message.streaming) continue
83
- const content = (excludeThinking ? stripThinking(message.content) : message.content).trim()
84
- lines.push(`## ${ROLE_LABEL[message.role]}`)
85
- lines.push('')
86
- lines.push(content === '' ? '(无内容)' : content)
87
- if (message.attachments !== undefined && message.attachments.length > 0) {
88
- lines.push('')
89
- lines.push(`> 📎 图片附件:${message.attachments.join('、')}`)
90
- }
91
- if (message.error !== undefined) {
92
- lines.push('')
93
- lines.push(`> ⚠️ 该条回复可能不完整:${message.error}`)
94
- }
95
- lines.push('')
96
- }
97
- return lines.join('\n').trim()
98
- }
99
-
100
- /** Render one transcript to markdown for harness consumption. */
101
- export function renderTranscriptMarkdown(transcript: WebChatTranscript, options?: { excludeThinking?: boolean }): string {
102
- const lines: string[] = []
103
- lines.push(`# 网页端对话记录:${transcript.title}`)
104
- lines.push('')
105
- lines.push(`- 来源:DeepSeek 网页端(chat.deepseek.com)· 模型 ${transcript.model}`)
106
- lines.push(`- 开始时间:${new Date(transcript.createdAt).toLocaleString()}`)
107
- lines.push(`- 消息数:${transcript.messages.length}`)
108
- lines.push('')
109
- lines.push('> 以下内容由 dsh-webchat 插件从 DeepSeek 网页端会话导出。')
110
- lines.push('')
111
- const body = renderMessagesMarkdown(transcript.messages, options)
112
- if (body !== '') lines.push(body)
113
- return lines.join('\n').trim() + '\n'
114
- }
115
-
116
- /**
117
- * Framing that turns the distilled brief into established context for the
118
- * agent — the analog of Codex's "another model started to solve this problem
119
- * and produced a summary; use it to build on the work already done".
120
- */
121
- export const HANDOFF_PREAMBLE = '这是一次从 DeepSeek 网页端会话(chat.deepseek.com)转来的上下文交接。下面的任务简报已把该对话提炼为可执行的任务上下文——把它当作既定目标与背景,直接在其基础上继续,不要复述。'
122
-
123
- /**
124
- * The distillation directive. Delivered as the final user message after the
125
- * raw transcript so the model condenses the exploration phase into the
126
- * execution-phase state representation. Mirrors Codex's handoff and DSH's
127
- * compaction checkpoint structure, tuned for "web chat → coding task".
128
- */
129
- const DISTILL_INSTRUCTION = [
130
- 'You are distilling a web-chat conversation (a user exploring and planning with a DeepSeek web model) into an executable task brief for a coding agent that will resume this work in a FRESH session WITHOUT the raw conversation.',
131
- '',
132
- 'Output EXACTLY the Markdown structure below — every section, in order, terse bullets, "(none)" for an empty section:',
133
- '',
134
- '## Objective',
135
- '- [the concrete goal/task to execute; quote the user\'s exact wording where it matters]',
136
- '',
137
- '## Established Context',
138
- '- [decisions, constraints, requirements, and facts already settled]',
139
- '',
140
- '## Current State',
141
- '- [what has been designed, decided, or produced so far]',
142
- '',
143
- '## Next Steps',
144
- '- [concrete ordered actions the coding agent should take]',
145
- '',
146
- '## Open Questions & Risks',
147
- '- [anything unresolved, uncertain, or risky]',
148
- '',
149
- 'Rules:',
150
- '- Terse, concrete engineering prose. Preserve exact identifiers, paths, commands, error strings, code snippets, and numeric values.',
151
- '- Do not invent facts; mark uncertainty explicitly.',
152
- '- Do not mention this distillation request or the web-chat source.',
153
- '- Output only the brief.',
154
- ].join('\n')
155
-
156
- /** Transfer distillation settings (resolved from the plugin config surface). */
157
- export interface DistillConfig {
158
- /** When true (default), distill the transcript into a task brief via ctx.llm. */
159
- distill: boolean
160
- /** Provider route for the distillation call; empty = auto-detect. */
161
- provider: string
162
- /** Model id for the distillation call; empty = auto-detect. */
163
- model: string
164
- /** Output-token cap for the final brief (single-shot / reduce). Default 4096. */
165
- maxTokens?: number
166
- /** Output-token cap for each per-chunk map summary. Default 1024. */
167
- chunkTokens?: number
168
- }
169
-
170
- /** Default output-token cap for the final distillation brief. */
171
- export const DEFAULT_TRANSFER_MAX_TOKENS = 4_096
172
- /** Default output-token cap for one chunk summary in the map phase. */
173
- export const DEFAULT_TRANSFER_CHUNK_TOKENS = 1_024
174
- /** Character budget per chunk when splitting a long transcript for map-reduce. */
175
- export const CHUNK_CHAR_BUDGET = 12_000
176
-
177
- /** A successful distillation result. */
178
- export interface DistillResult {
179
- brief: string
180
- provider: string
181
- model: string
182
- }
183
-
184
- /** Pick a provider/model for the one-shot distillation call. */
185
- async function resolveDistillTarget(llm: LlmRuntime, provider: string, model: string): Promise<{ provider: string; model: string } | undefined> {
186
- if (provider !== '' && model !== '') return { provider, model }
187
- const providers = llm.listProviders()
188
- if (providers.length === 0) return undefined
189
- const baseProvider = provider !== ''
190
- ? provider
191
- : (providers.find(entry => entry.id.toLowerCase().includes('deepseek')) ?? providers[0]).id
192
- if (model !== '') return { provider: baseProvider, model }
193
- const models = await llm.listModels(baseProvider)
194
- const picked = models.find(entry => entry.id.toLowerCase().includes('chat')) ?? models[0]
195
- return picked === undefined ? undefined : { provider: baseProvider, model: picked.id }
196
- }
197
-
198
- /**
199
- * Split a transcript's messages into chunks of at most `budget` characters
200
- * (sum of `message.content.length`), never splitting a single message: a
201
- * message larger than the budget becomes its own (oversized) chunk. Streaming
202
- * assistant messages are skipped (they were never completed).
203
- */
204
- export function chunkTranscript(transcript: WebChatTranscript, budget: number = CHUNK_CHAR_BUDGET): WebChatMessage[][] {
205
- const chunks: WebChatMessage[][] = []
206
- let current: WebChatMessage[] = []
207
- let size = 0
208
- for (const message of transcript.messages) {
209
- if (message.role === 'assistant' && message.streaming) continue
210
- const messageSize = Math.max(1, message.content.length)
211
- if (current.length > 0 && size + messageSize > budget) {
212
- chunks.push(current)
213
- current = []
214
- size = 0
215
- }
216
- current.push(message)
217
- size += messageSize
218
- }
219
- if (current.length > 0) chunks.push(current)
220
- return chunks
221
- }
222
-
223
- /**
224
- * The map-phase directive: condense one slice of a long conversation into
225
- * dense notes a later synthesis step merges. Terse, factual, uncertainty-marked.
226
- */
227
- const CHUNK_SUMMARY_INSTRUCTION = [
228
- 'You are condensing one slice of a long web-chat conversation (a user exploring and planning with a DeepSeek web model) into dense notes that a later synthesis step will merge into a task brief.',
229
- '',
230
- 'Preserve exactly, and do not invent:',
231
- '- decisions, constraints, requirements, and settled facts',
232
- '- exact identifiers, paths, commands, error strings, code snippets, and numeric values',
233
- '- anything still open, uncertain, or risky',
234
- '',
235
- 'Output compact markdown notes. Mark uncertainty explicitly. Do not mention this summarization request or the source. Output only the notes.',
236
- ].join('\n')
237
-
238
- /** Run one LLM distillation call and return the assembled text (undefined on failure). */
239
- async function runDistillCall(llm: LlmRuntime, target: { provider: string; model: string }, instruction: string, maxTokens: number): Promise<string | undefined> {
240
- const assembler = new BlockAssembler()
241
- try {
242
- for await (const chunk of llm.stream({
243
- provider: target.provider,
244
- model: target.model,
245
- messages: [createUserMessage({
246
- content: [{ type: 'text', text: instruction }],
247
- source: { kind: 'plugin', plugin: 'webchat' },
248
- })],
249
- maxTokens,
250
- purpose: 'compaction',
251
- })) {
252
- assembler.push(chunk)
253
- }
254
- } catch {
255
- return undefined
256
- }
257
- const finish = assembler.finish
258
- if (finish.kind !== 'stop' && finish.kind !== 'max-tokens') return undefined
259
- const text = assembler.blocks()
260
- .filter((block): block is { type: 'text'; text: string } => block.type === 'text')
261
- .map(block => block.text)
262
- .join('\n')
263
- .trim()
264
- return text === '' ? undefined : text
265
- }
266
-
267
- /**
268
- * Distill a web transcript into an executable task brief via the harness LLM.
269
- * Long transcripts are distilled with map-reduce: each chunk is summarized
270
- * (map, capped at `chunkTokens`), then the summaries are merged into the final
271
- * brief (reduce, capped at `maxTokens`). Short transcripts take a single shot.
272
- * Returns undefined (so callers fall back to the raw transcript) when the LLM
273
- * service, a provider/model, or a clean completion is unavailable.
274
- */
275
- export async function distillTranscriptToBrief(ctx: Context, transcript: WebChatTranscript, config: DistillConfig): Promise<DistillResult | undefined> {
276
- const llm = ctx.get('llm') as LlmRuntime | undefined
277
- if (llm === undefined) return undefined
278
- const target = await resolveDistillTarget(llm, config.provider, config.model).catch(() => undefined)
279
- if (target === undefined) return undefined
280
-
281
- const maxTokens = config.maxTokens !== undefined && config.maxTokens > 0 ? config.maxTokens : DEFAULT_TRANSFER_MAX_TOKENS
282
- const chunkTokens = config.chunkTokens !== undefined && config.chunkTokens > 0 ? config.chunkTokens : DEFAULT_TRANSFER_CHUNK_TOKENS
283
- const chunks = chunkTranscript(transcript)
284
-
285
- let source: string
286
- if (chunks.length <= 1) {
287
- // Single shot: distill the whole transcript directly.
288
- source = `${DISTILL_INSTRUCTION}\n\n--- 网页对话记录 ---\n\n${renderTranscriptMarkdown(transcript, { excludeThinking: true })}`
289
- } else {
290
- // Map: summarize each chunk; a failed map falls back to a truncated raw
291
- // excerpt so no information is silently dropped.
292
- const summaries: string[] = []
293
- for (let i = 0; i < chunks.length; i++) {
294
- const chunkMarkdown = renderMessagesMarkdown(chunks[i], { excludeThinking: true })
295
- const summary = await runDistillCall(
296
- llm,
297
- target,
298
- `${CHUNK_SUMMARY_INSTRUCTION}\n\n--- 片段 ${i + 1} / ${chunks.length} ---\n\n${chunkMarkdown}`,
299
- chunkTokens,
300
- )
301
- summaries.push(summary ?? `(片段 ${i + 1} 摘要失败,截取原文)\n${chunkMarkdown.slice(0, CHUNK_CHAR_BUDGET)}`)
302
- }
303
- source = `${DISTILL_INSTRUCTION}\n\n--- 网页对话片段摘要(共 ${chunks.length} 段,已按段摘要) ---\n\n${summaries.join('\n\n---\n\n')}`
304
- }
305
-
306
- const brief = await runDistillCall(llm, target, source, maxTokens)
307
- if (brief === undefined) return undefined
308
- return { brief, provider: target.provider, model: target.model }
309
- }
310
-
311
- /** Build a user-message surface event carrying the handoff text at a given seq. */
312
- export function transcriptUserMessageEvent(markdown: string, seq: number): SessionEvent<'user/message'> {
313
- return {
314
- type: 'user/message',
315
- seq,
316
- time: Date.now(),
317
- // Surface events must declare how they entered the ordered surface; a
318
- // seeded user prompt appends to the tail.
319
- surfaceOp: 'append',
320
- data: {
321
- id: randomUUID() as MessageId,
322
- role: 'user',
323
- content: [{ type: 'text', text: markdown }],
324
- source: { kind: 'plugin', plugin: 'webchat' },
325
- },
326
- }
327
- }
328
-
329
- /** Build the seed user-message event carrying the handoff text (seq 0). */
330
- export function transcriptSeedEvent(markdown: string): SessionEvent<'user/message'> {
331
- return transcriptUserMessageEvent(markdown, 0)
332
- }
333
-
334
- /**
335
- * Sanitize a web-chat title into a safe single-line session title (the host
336
- * session-title service strips control characters and collapses whitespace;
337
- * mirror that lightly so a transferred title never overflows the fold).
338
- */
339
- function normalizeSessionTitleText(text: string): string {
340
- const cleaned = text
341
- // eslint-disable-next-line no-control-regex
342
- .replace(/[\u0000-\u001F\u007F-\u009F]/g, '')
343
- .replace(/\s+/g, ' ')
344
- .trim()
345
- return Array.from(cleaned).slice(0, 80).join('')
346
- }
347
-
348
- /**
349
- * Build the durable `session/title` event that pins the transferred session's
350
- * display name to the web chat's title. The `session/title` type is a
351
- * plugin-merged extension of `SessionEventMap` (from dsh-session-title), so it
352
- * is not in this package's compiled `SessionEvent` union — cast through
353
- * `unknown`. `source.kind: 'user'` pins the title against auto-regeneration.
354
- */
355
- function transcriptTitleEvent(title: string, seq: number, time: number): SessionEvent {
356
- return {
357
- type: 'session/title',
358
- seq,
359
- time,
360
- data: {
361
- title: normalizeSessionTitleText(title),
362
- messageSeqs: [],
363
- source: { kind: 'user' },
364
- },
365
- } as unknown as SessionEvent
366
- }
367
-
368
- /** Validate/normalize a workspace directory (must be absolute). */
369
- function normalizeCwd(cwd: string | undefined): string {
370
- const resolved = cwd === undefined || cwd === '' ? process.cwd() : cwd
371
- if (!resolved.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(resolved)) {
372
- throw new Error(`cwd 必须是绝对路径,收到: ${resolved}`)
373
- }
374
- return resolved
375
- }
376
-
377
- export interface TransferWorkspaceTarget {
378
- /** Stable workspace id (from the registry / GUI picker); wins over `path`. */
379
- workspaceId?: string
380
- /** Directory path to use as the session cwd (optionally resolves to a workspace). */
381
- path?: string
382
- }
383
-
384
- export interface TransferToSessionInput {
385
- transcript: WebChatTranscript
386
- cwd?: string
387
- /** Target workspace; when set, the session is grouped under it (attached). */
388
- workspace?: TransferWorkspaceTarget
389
- /**
390
- * Existing harness session to CONTINUE instead of creating a new one. When
391
- * set, the distilled brief (or raw transcript) is appended as a fresh user
392
- * message to that session rather than seeding a new session.
393
- */
394
- targetSessionId?: string
395
- }
396
-
397
- /** Resolved transfer destination: the session cwd plus an optional owning workspace. */
398
- export interface ResolvedTransferTarget {
399
- cwd: string
400
- workspace?: Workspace
401
- }
402
-
403
- /** The result of creating a transferred harness session. */
404
- export interface TransferToSessionResult {
405
- sessionId: string
406
- distilled: boolean
407
- /** True when the session was attached to a workspace; false = ungrouped. */
408
- attached: boolean
409
- /** Workspace id the session landed in, when attached. */
410
- workspaceId?: string
411
- }
412
-
413
- /** Access the optional workspace registry without a hard service dependency. */
414
- function workspaceRegistryOf(ctx: Context): WorkspaceRegistry | undefined {
415
- return ctx.get('workspaceRegistry') as WorkspaceRegistry | undefined
416
- }
417
-
418
- /**
419
- * Resolve the transfer destination. A `workspace.workspaceId` is validated
420
- * BEFORE the session is persisted so an unknown id fails fast instead of
421
- * leaving an orphan ungrouped session; `workspace.path` is realpath-canonicalized
422
- * (an existing directory) and opportunistically resolved to a workspace; with
423
- * no workspace the existing `cwd` behavior applies unchanged.
424
- */
425
- async function resolveTransferTarget(ctx: Context, input: TransferToSessionInput): Promise<ResolvedTransferTarget> {
426
- const registry = workspaceRegistryOf(ctx)
427
- const target = input.workspace
428
-
429
- if (target?.workspaceId !== undefined && target.workspaceId !== '') {
430
- if (registry === undefined) {
431
- throw new Error(`无法归入工作区 ${target.workspaceId}:当前部署未挂载工作区服务`)
432
- }
433
- const workspace = registry.get(target.workspaceId as WorkspaceId)
434
- if (workspace === undefined) {
435
- throw new Error(`工作区 ${target.workspaceId} 不存在或已删除`)
436
- }
437
- return { cwd: workspace.path, workspace }
438
- }
439
-
440
- if (target?.path !== undefined && target.path !== '') {
441
- let cwd: string
442
- try {
443
- cwd = await realpath(target.path)
444
- } catch {
445
- throw new Error(`工作区路径不可用(不存在或不是目录):${target.path}`)
446
- }
447
- const workspace = registry === undefined ? undefined : await registry.resolveByPath(cwd).catch(() => undefined)
448
- return { cwd, workspace }
449
- }
450
-
451
- return { cwd: normalizeCwd(input.cwd) }
452
- }
453
-
454
- /**
455
- * Continue an EXISTING harness session by appending the handoff as a fresh
456
- * user message — the "same task, another web round" resume path. The session's
457
- * stored log is loaded (which also durably closes any crash-orphaned turn) to
458
- * learn the next contiguous seq and turn number, then an open turn is appended
459
- * carrying the message; the resume loop closes the open turn and claims the
460
- * message as pending input, exactly like a queued user prompt. No new session
461
- * or header is created, so the target keeps its cwd/title/workspace.
462
- */
463
- async function appendToExistingSession(ctx: Context, sessionId: string, markdown: string, distilled: boolean): Promise<TransferToSessionResult> {
464
- const persistence = sessionPersistenceOf(ctx)
465
- if (persistence === undefined) {
466
- throw new Error('未找到会话持久化后端,无法延续已有会话')
467
- }
468
- const id = SessionId(sessionId)
469
- // dsh ≥ 0.2 addresses storage through a per-session handle: opening for write
470
- // carries both the read and the append, so `load(id)` + `append(id, events)`
471
- // collapse into one handle. A session the GUI already holds is owned by that
472
- // handle and refuses a second writer — reported as a plain actionable error.
473
- let handle: SessionPersistenceHandle
474
- try {
475
- handle = await persistence.open(id, 'write')
476
- } catch (error) {
477
- const reason = error instanceof Error ? error.message : String(error)
478
- throw new Error(`无法以写入方式打开该会话(它可能已在界面上打开并持有写租约):${reason}`)
479
- }
480
-
481
- try {
482
- const inspection = await handle.read(0)
483
-
484
- let nextSeq = 0
485
- let maxTurn = 0
486
- for (const event of inspection.events) {
487
- if (event.seq >= nextSeq) nextSeq = event.seq + 1
488
- const turn = (event as { data?: { turn?: number } }).data?.turn
489
- if (typeof turn === 'number' && turn > maxTurn) maxTurn = turn
490
- }
491
- const turn = maxTurn + 1
492
- const now = Date.now()
493
-
494
- // Open a turn + step and enter the user message, leaving both open so the
495
- // resume path closes them (`turn/end` interrupted) and claims the message.
496
- const appended: SessionEvent[] = [
497
- { type: 'turn/start', seq: nextSeq, time: now, data: { turn } },
498
- { type: 'step/start', seq: nextSeq + 1, time: now, data: { turn, step: 1 } },
499
- transcriptUserMessageEvent(markdown, nextSeq + 2),
500
- ]
501
- await handle.append(appended)
502
- // Durability barrier: the resume loop must observe the appended turn.
503
- if (typeof handle.flush === 'function') await handle.flush()
504
- } finally {
505
- if (typeof handle.close === 'function') await handle.close()
506
- }
507
-
508
- return { sessionId, distilled, attached: false }
509
- }
510
-
511
- /**
512
- * Create a new COLD harness session seeded with a distilled task brief (or the
513
- * raw transcript when the user chooses 'raw' / distillation is unavailable),
514
- * written straight through the session-persistence backend so the GUI lists it
515
- * and can resume it later (no live-store ownership). When `input.workspace`
516
- * names a registered workspace, the session's cwd is set to that workspace's
517
- * canonical path and the session is attached to the workspace's account, so
518
- * the GUI groups it under that workspace instead of "ungrouped".
519
- *
520
- * `mode` is the user's explicit choice; when undefined the plugin config
521
- * default (`transferDistill`) applies.
522
- */
523
- export async function transferToHarnessSession(ctx: Context, input: TransferToSessionInput, config: DistillConfig, mode?: TransferMode): Promise<TransferToSessionResult> {
524
- const rawMarkdown = renderTranscriptMarkdown(input.transcript, { excludeThinking: true })
525
-
526
- const shouldDistill = mode === 'distill' ? true : mode === 'raw' ? false : config.distill
527
- let seedMarkdown = `${HANDOFF_PREAMBLE}\n\n${rawMarkdown}`
528
- let distilled = false
529
- if (shouldDistill) {
530
- const result = await distillTranscriptToBrief(ctx, input.transcript, config)
531
- if (result !== undefined) {
532
- seedMarkdown = `${HANDOFF_PREAMBLE}\n\n${result.brief}\n\n> (已由 ${result.provider}/${result.model} 从网页对话蒸馏生成)`
533
- distilled = true
534
- }
535
- }
536
-
537
- // Continue an existing session instead of creating a new one.
538
- if (input.targetSessionId !== undefined && input.targetSessionId !== '') {
539
- return appendToExistingSession(ctx, input.targetSessionId, seedMarkdown, distilled)
540
- }
541
-
542
- const target = await resolveTransferTarget(ctx, input)
543
- const id = SessionId(`session-${randomUUID()}`)
544
- const createdAt = Date.now()
545
- const header: SessionHeader = { version: SESSION_FORMAT_VERSION, id, createdAt, cwd: target.cwd, delegationDepth: 0 }
546
-
547
- // Seed the handoff message, then pin the display name to the web chat's
548
- // title (seq 1, immediately after the seed) so the GUI list shows the chat
549
- // title instead of falling back to the cwd basename or the raw session id.
550
- const seedEvent = transcriptSeedEvent(seedMarkdown)
551
- const title = normalizeSessionTitleText(input.transcript.title)
552
- const events: SessionEvent[] = [seedEvent]
553
- if (title !== '') events.push(transcriptTitleEvent(title, seedEvent.seq + 1, seedEvent.time))
554
-
555
- const persistence = sessionPersistenceOf(ctx)
556
- if (persistence !== undefined) {
557
- // Cold path: create the stored session, then write the seed + title events
558
- // through its write handle and materialize them, so the GUI (and other
559
- // processes) list the session and can resume it. `append`'s contiguous-seq
560
- // contract starts at 0 for a fresh session, matching the seed event's seq.
561
- // `session/end-seed` is re-added on resume, so it is not written here.
562
- const handle = await persistence.create(header)
563
- try {
564
- await handle.append(events)
565
- if (typeof handle.flush === 'function') await handle.flush()
566
- } finally {
567
- if (typeof handle.close === 'function') await handle.close()
568
- }
569
- } else {
570
- // No persistence backend mounted (the deployment has no resume path).
571
- ctx.sessions.create(id, { meta: { cwd: target.cwd }, seed: events })
572
- }
573
-
574
- let attached = false
575
- if (target.workspace !== undefined) {
576
- try {
577
- await target.workspace.attachSession(id)
578
- attached = true
579
- } catch {
580
- // Non-fatal: the session is already persisted; it simply stays ungrouped.
581
- attached = false
582
- }
583
- }
584
- const workspaceId = attached && target.workspace !== undefined ? target.workspace.id : undefined
585
- return { sessionId: id, distilled, attached, workspaceId }
586
- }
587
-
588
- export interface ExportTranscriptInput {
589
- transcript: WebChatTranscript
590
- cwd?: string
591
- }
592
-
593
- /** Write the transcript markdown into the target directory; returns the path. */
594
- export function exportTranscriptFile(input: ExportTranscriptInput): { filePath: string } {
595
- const cwd = normalizeCwd(input.cwd)
596
- const slug = input.transcript.title.replace(/[^\w\u4e00-\u9fa5-]+/g, '-').replace(/-+/g, '-').slice(0, 60) || 'webchat'
597
- const fileName = `webchat-${slug}-${input.transcript.id.slice(-6)}.md`
598
- const filePath = join(cwd, fileName)
599
- mkdirSync(cwd, { recursive: true })
600
- writeFileSync(filePath, renderTranscriptMarkdown(input.transcript), 'utf8')
601
- return { filePath: basename(filePath) }
602
- }