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