@changfenhuang/dsh-genui 0.11.2 → 0.11.4
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/CHANGELOG.md +25 -0
- package/README.md +13 -4
- package/README.zh-CN.md +13 -4
- package/SKILL.md +16 -4
- package/lib/assets/standalone-runtime.js +7 -6
- package/lib/client.js +15 -17
- package/lib/index.js +956 -354
- package/lib/types/client/blocks/forms.d.ts +5 -6
- package/lib/types/client/blocks/state.d.ts +2 -11
- package/lib/types/client/spec.d.ts +8 -11
- package/lib/types/client/submission-registry.d.ts +51 -0
- package/lib/types/client/table-details.d.ts +20 -0
- package/lib/types/client/walk-spec.d.ts +3 -0
- package/lib/types/plugin/fence-feedback.d.ts +50 -11
- package/lib/types/plugin/index.d.ts +3 -3
- package/lib/types/shared/fence-repair.d.ts +18 -7
- package/package.json +20 -19
- package/src/client/GenuiBlock.module.css +65 -1
- package/src/client/GenuiBlock.tsx +7 -14
- package/src/client/blocks/charts.tsx +41 -8
- package/src/client/blocks/forms.tsx +26 -48
- package/src/client/blocks/render-node.tsx +1 -1
- package/src/client/blocks/state.ts +2 -12
- package/src/client/dom-fence.tsx +94 -21
- package/src/client/genui-runtime/diagnostics.ts +20 -0
- package/src/client/genui-runtime/normalize.ts +28 -9
- package/src/client/guard.ts +93 -151
- package/src/client/inline.ts +11 -4
- package/src/client/spec.ts +8 -11
- package/src/client/submission-registry.ts +150 -0
- package/src/client/table-details.ts +151 -0
- package/src/client/walk-spec.ts +43 -0
- package/src/plugin/fence-feedback.ts +372 -53
- package/src/plugin/index.ts +7 -6
- package/src/shared/fence-repair.ts +309 -128
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
*
|
|
9
9
|
* The loop is deliberately narrow, matching the contract agreed on the issue:
|
|
10
10
|
* - **默认开启。** 插件配置中的 `fenceFeedback: false` 可以关闭回合转向。
|
|
11
|
-
* -
|
|
12
|
-
*
|
|
11
|
+
* - **修正上限。** 每个 turn 最多发送两条 correction;render failure 和 delivery reminder 共用上限,
|
|
12
|
+
* 每个 fence body 在当前回合中最多修正一次,新回合可重新修正。
|
|
13
|
+
* - **仅含 reasoning 的恢复。** 已验证的 GenUI 回合若只以 reasoning block 结束,使用宿主的
|
|
14
|
+
* `EMPTY_RESPONSE` retry policy。
|
|
13
15
|
* - **Never for subagents.** A child session's fence belongs to a parent reply.
|
|
14
16
|
* - **Exact fence matching.** Only an info string of exactly `dsh-ui` opens a
|
|
15
17
|
* fence, so ` ```dsh-ui-dark `, indented prose, or a mention of the name is
|
|
@@ -25,8 +27,8 @@
|
|
|
25
27
|
|
|
26
28
|
import type { Context } from '@deepseek-ai/cordis'
|
|
27
29
|
import type {} from '@deepseek-ai/dsh-agent'
|
|
28
|
-
import type {
|
|
29
|
-
import type { UserMessage } from '@deepseek-ai/dsh-session'
|
|
30
|
+
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
|
31
|
+
import type { Session, SessionEvent, SessionStore, UserMessage } from '@deepseek-ai/dsh-session'
|
|
30
32
|
import { createHash, randomUUID } from 'node:crypto'
|
|
31
33
|
import { droppedNodeFailure } from './genui-diagnostic.ts'
|
|
32
34
|
import { resolveFence } from '../shared/fence-resolve.ts'
|
|
@@ -38,6 +40,12 @@ export const FEEDBACK_SOURCE_KIND = `plugin:${FEEDBACK_PLUGIN_NAME}` as const
|
|
|
38
40
|
|
|
39
41
|
/** Marker prefix inside the correction text: `[genui-fence-repair #<fingerprint>]`. */
|
|
40
42
|
const MARKER_PREFIX = '[genui-fence-repair #'
|
|
43
|
+
/**
|
|
44
|
+
* 同一个 turn 内 fence-feedback correction 的共享上限。
|
|
45
|
+
* Render failure 和 nothing-delivered reminder 共用该 budget;各自的 ledger 独立阻止重复修正。
|
|
46
|
+
*/
|
|
47
|
+
export const MAX_CORRECTIONS_PER_TURN = 2
|
|
48
|
+
|
|
41
49
|
/** Marker prefix written by older plugin versions. */
|
|
42
50
|
const LEGACY_MARKER_PREFIX = '[genui 自修 #'
|
|
43
51
|
|
|
@@ -177,21 +185,64 @@ export function createFeedbackMessage(text: string, sessionFormatVersion: number
|
|
|
177
185
|
|
|
178
186
|
/** Per-session bookkeeping for the loop (process lifetime). */
|
|
179
187
|
interface SessionFeedback {
|
|
180
|
-
/** Latest assistant reply text of the current turn. */
|
|
188
|
+
/** Latest assistant reply text of the current turn (fences are read from here). */
|
|
181
189
|
text: string
|
|
182
|
-
/**
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
190
|
+
/**
|
|
191
|
+
* A `validate_dsh_ui` call happened in this turn. This is the FORMAL signal
|
|
192
|
+
* that the turn is GenUI-related — the loop never guesses intent by scanning
|
|
193
|
+
* the reasoning block.
|
|
194
|
+
*/
|
|
195
|
+
validatedThisTurn: boolean
|
|
196
|
+
/** 子代理会话的输出不会直接交给用户。 */
|
|
197
|
+
isSubagent: boolean
|
|
198
|
+
/**
|
|
199
|
+
* The turn delivered something formal: a non-empty body text, or a
|
|
200
|
+
* `render_ui` call whose RESULT reports success. A failed render is not an
|
|
201
|
+
* answer, and a call whose result has not arrived yet proves nothing.
|
|
202
|
+
*/
|
|
203
|
+
deliveredThisTurn: boolean
|
|
204
|
+
/**
|
|
205
|
+
* `render_ui` calls of this turn whose result has not arrived yet. The
|
|
206
|
+
* result — not the call — decides delivery, so an outstanding call blocks
|
|
207
|
+
* steering at the boundary instead of being guessed about.
|
|
208
|
+
*/
|
|
209
|
+
pendingRenders: Set<string>
|
|
210
|
+
/** Fence fingerprints already corrected for a RENDER failure in the current turn. */
|
|
211
|
+
correctedSpec: Set<string>
|
|
212
|
+
/**
|
|
213
|
+
* Turns already given the "nothing was delivered" reminder. Kept separate from
|
|
214
|
+
* {@link correctedSpec}: a missing delivery must not consume the render-failure
|
|
215
|
+
* ledger (nor the other way round).
|
|
216
|
+
*/
|
|
217
|
+
deliveryRemindedTurns: Set<number>
|
|
218
|
+
/** Corrections already steered in {@link correctionsTurn}. */
|
|
219
|
+
correctionsThisTurn: number
|
|
220
|
+
/** Turn {@link correctionsThisTurn} counts. */
|
|
221
|
+
correctionsTurn: number | undefined
|
|
222
|
+
/** 从 session event 中恢复的当前 turn。 */
|
|
223
|
+
currentTurn: number | undefined
|
|
224
|
+
/** 已计入 correction budget 的 message ID。 */
|
|
225
|
+
accountedCorrectionMessageIds: Set<string>
|
|
186
226
|
}
|
|
187
227
|
|
|
188
228
|
/** What the pure planner needs to decide whether a correction may be sent. */
|
|
189
229
|
export interface FenceFeedbackPlanInput {
|
|
230
|
+
/** Latest assistant reply text of the current turn. */
|
|
190
231
|
readonly text: string
|
|
191
232
|
readonly turn: number
|
|
192
|
-
|
|
193
|
-
readonly
|
|
233
|
+
/** Fence bodies already corrected for a render failure in the current turn. */
|
|
234
|
+
readonly correctedSpec: ReadonlySet<string>
|
|
235
|
+
/** Turns already given the delivery reminder. */
|
|
236
|
+
readonly deliveryRemindedTurns?: ReadonlySet<number> | undefined
|
|
237
|
+
/** A `validate_dsh_ui` call happened this turn (formal GenUI signal). */
|
|
238
|
+
readonly validatedThisTurn?: boolean | undefined
|
|
239
|
+
/** The turn already delivered a body or a successful `render_ui` result. */
|
|
240
|
+
readonly deliveredThisTurn?: boolean | undefined
|
|
194
241
|
readonly aborted: boolean
|
|
242
|
+
/** Corrections already steered in this turn (shared hard cap). */
|
|
243
|
+
readonly correctionsThisTurn?: number | undefined
|
|
244
|
+
/** Turn {@link correctionsThisTurn} counts (stale counts are ignored). */
|
|
245
|
+
readonly correctionsTurn?: number | undefined
|
|
195
246
|
}
|
|
196
247
|
|
|
197
248
|
/** A correction the caller must account for before steering. */
|
|
@@ -199,26 +250,100 @@ export interface FenceFeedbackPlan {
|
|
|
199
250
|
readonly text: string
|
|
200
251
|
readonly fingerprints: readonly string[]
|
|
201
252
|
readonly turn: number
|
|
253
|
+
/**
|
|
254
|
+
* `render` corrects a fence that failed to resolve; `delivery` reminds the
|
|
255
|
+
* model that the turn produced nothing formal. They share the per-turn budget
|
|
256
|
+
* but keep separate ledgers.
|
|
257
|
+
*/
|
|
258
|
+
readonly kind: 'render' | 'delivery'
|
|
202
259
|
}
|
|
203
260
|
|
|
204
261
|
/**
|
|
205
|
-
* Decide whether this turn boundary should steer a
|
|
206
|
-
*
|
|
207
|
-
*
|
|
262
|
+
* Decide whether this turn boundary should steer a correction — the pure core of
|
|
263
|
+
* the loop, so every bound (per-turn cap, per-fence ledger, cancellation) is
|
|
264
|
+
* testable without a host.
|
|
208
265
|
*
|
|
209
|
-
*
|
|
266
|
+
* The decision is driven by FORMAL events only: fences in the reply body, a
|
|
267
|
+
* `validate_dsh_ui` call, a delivered body text or a successful `render_ui`
|
|
268
|
+
* result. The reasoning block is
|
|
269
|
+
* never read here — a draft inside the thinking block is not proof that the model
|
|
270
|
+
* chose to deliver it, so it must not change any decision.
|
|
271
|
+
*
|
|
272
|
+
* @param input - reply text, turn identity, formal signals, and the accounting.
|
|
210
273
|
* @returns the correction to send, or null when the loop must stay silent.
|
|
211
274
|
*/
|
|
212
275
|
export function planFenceFeedback(input: FenceFeedbackPlanInput): FenceFeedbackPlan | null {
|
|
213
276
|
if (input.aborted) return null
|
|
214
|
-
|
|
215
|
-
if (
|
|
216
|
-
const failures = fenceFailures(input.text).filter(failure => !input.
|
|
217
|
-
if (failures.length
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
277
|
+
const used = input.correctionsTurn === input.turn ? (input.correctionsThisTurn ?? 0) : 0
|
|
278
|
+
if (used >= MAX_CORRECTIONS_PER_TURN) return null
|
|
279
|
+
const failures = fenceFailures(input.text).filter(failure => !input.correctedSpec.has(failure.fingerprint))
|
|
280
|
+
if (failures.length > 0) {
|
|
281
|
+
return {
|
|
282
|
+
text: fenceCorrectionText(failures),
|
|
283
|
+
fingerprints: failures.map(failure => failure.fingerprint),
|
|
284
|
+
turn: input.turn,
|
|
285
|
+
kind: 'render',
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
// No un-corrected render failure. If the turn formally validated a spec and
|
|
289
|
+
// then delivered nothing, remind the model once for this turn.
|
|
290
|
+
if (input.validatedThisTurn !== true || input.deliveredThisTurn === true) return null
|
|
291
|
+
if (input.deliveryRemindedTurns?.has(input.turn) === true) return null
|
|
292
|
+
return { text: missingBodyCorrectionText(input.turn, used + 1), fingerprints: [], turn: input.turn, kind: 'delivery' }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Correction for a validated GenUI turn that reached the boundary without any
|
|
297
|
+
* formal delivery.
|
|
298
|
+
*
|
|
299
|
+
* @param turn - turn that reached the boundary.
|
|
300
|
+
* @param attempt - correction number within the shared turn budget.
|
|
301
|
+
* @returns the message text to steer into the running turn.
|
|
302
|
+
*/
|
|
303
|
+
export function missingBodyCorrectionText(turn: number, attempt = 1): string {
|
|
304
|
+
const head = `${MARKER_PREFIX}turn-${turn}]\n\n[genui-fence-repair]\nstatus=nothing_delivered\nfences=0\nnext=emit_fence_in_body\nrepeat_rendered_content=false\nreply_language=conversation\n\n`
|
|
305
|
+
const emphasis = attempt <= 1 ? '' : `(第 ${attempt} 次提醒)`
|
|
306
|
+
return `${head}本轮尚未产生正式回答,也没有通过支持的通道交付结果${emphasis}。请根据用户当前请求完成正式答复;需要 UI 时,在回答正文输出你最终选定的 dsh-ui 围栏,或明确调用 render_ui。可以修改或放弃此前候选;不能完成时,请在正文说明原因。\n`
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* 将 GenUI 回合中仅含 reasoning 的完整响应转换为宿主已有的可重试空响应错误。
|
|
311
|
+
*
|
|
312
|
+
* @param options - LLM stream waterfall 拦截的请求。
|
|
313
|
+
* @param source - 本次请求的 provider stream。
|
|
314
|
+
* @returns 原始 stream;仅符合条件的终止 stop 会被改写。
|
|
315
|
+
*/
|
|
316
|
+
async function* retryReasoningOnlyGenuiStream(
|
|
317
|
+
options: GenerateOptions,
|
|
318
|
+
source: AsyncIterable<StreamChunk>,
|
|
319
|
+
): AsyncGenerator<StreamChunk> {
|
|
320
|
+
const { EMPTY_RESPONSE_CODE, isAgentLoopRequest } = await import('@deepseek-ai/dsh-llm')
|
|
321
|
+
if (!isAgentLoopRequest(options)) {
|
|
322
|
+
yield* source
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
let hasReasoningBlock = false
|
|
327
|
+
let hasOtherBlock = false
|
|
328
|
+
for await (const chunk of source) {
|
|
329
|
+
if (chunk.type === 'block-end') {
|
|
330
|
+
if (chunk.block.type === 'reasoning') hasReasoningBlock = true
|
|
331
|
+
else hasOtherBlock = true
|
|
332
|
+
}
|
|
333
|
+
if (chunk.type === 'finish' && chunk.reason.kind === 'stop' && hasReasoningBlock && !hasOtherBlock) {
|
|
334
|
+
yield {
|
|
335
|
+
type: 'finish',
|
|
336
|
+
reason: {
|
|
337
|
+
kind: 'error',
|
|
338
|
+
failure: {
|
|
339
|
+
message: 'GenUI turn completed with reasoning only and no deliverable response',
|
|
340
|
+
code: EMPTY_RESPONSE_CODE,
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
}
|
|
344
|
+
} else {
|
|
345
|
+
yield chunk
|
|
346
|
+
}
|
|
222
347
|
}
|
|
223
348
|
}
|
|
224
349
|
|
|
@@ -235,9 +360,77 @@ function textOfContent(content: unknown): string {
|
|
|
235
360
|
.join('\n')
|
|
236
361
|
}
|
|
237
362
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
363
|
+
type RenderResultStatus = 'rendered' | 'invalid'
|
|
364
|
+
|
|
365
|
+
/** 读取 render_ui result protocol 中明确返回的 status。 */
|
|
366
|
+
function renderResultStatus(content: unknown): RenderResultStatus | undefined {
|
|
367
|
+
const lines = textOfContent(content).split(/\r?\n/u)
|
|
368
|
+
if (lines[0]?.trim() !== '[genui-render]') return undefined
|
|
369
|
+
for (const line of lines.slice(1)) {
|
|
370
|
+
if (line === 'status=rendered') return 'rendered'
|
|
371
|
+
if (line === 'status=invalid') return 'invalid'
|
|
372
|
+
}
|
|
373
|
+
return undefined
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
interface ObservedToolResult {
|
|
377
|
+
readonly callId: string
|
|
378
|
+
readonly isError: boolean
|
|
379
|
+
readonly content: unknown
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** 从 legacy wrapper 与 Session format v4 中提取 tool result 数据。 */
|
|
383
|
+
function observedToolResult(message: unknown): ObservedToolResult | null {
|
|
384
|
+
if (typeof message !== 'object' || message === null) return null
|
|
385
|
+
const record = message as { toolCallId?: unknown; isError?: unknown; content?: unknown }
|
|
386
|
+
if (typeof record.toolCallId === 'string') {
|
|
387
|
+
return { callId: record.toolCallId, isError: record.isError === true, content: record.content }
|
|
388
|
+
}
|
|
389
|
+
if (!Array.isArray(record.content)) return null
|
|
390
|
+
const block = record.content.find(part => typeof part === 'object' && part !== null
|
|
391
|
+
&& (part as { type?: unknown }).type === 'tool-result') as {
|
|
392
|
+
toolCallId?: unknown
|
|
393
|
+
isError?: unknown
|
|
394
|
+
content?: unknown
|
|
395
|
+
} | undefined
|
|
396
|
+
if (typeof block?.toolCallId !== 'string') return null
|
|
397
|
+
return { callId: block.toolCallId, isError: block.isError === true, content: block.content }
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** Tool whose SUCCESSFUL result is a formal delivery. */
|
|
401
|
+
const DELIVERY_TOOL = 'render_ui'
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Whether one assistant message delivered a non-empty body text.
|
|
405
|
+
*
|
|
406
|
+
* Deliberately narrow: a non-empty text block. A `render_ui` call is decided
|
|
407
|
+
* by its `tool/result`, not by the call appearing in a message — a call that
|
|
408
|
+
* failed (or whose result has not arrived) is not a delivery. Other tools
|
|
409
|
+
* (validate_dsh_ui, bash, …) may succeed without producing any answer, so
|
|
410
|
+
* they are NOT counted as delivery.
|
|
411
|
+
*
|
|
412
|
+
* @param content - assistant content blocks.
|
|
413
|
+
* @returns true when this message delivered a body text.
|
|
414
|
+
*/
|
|
415
|
+
function deliveredBodyText(content: unknown): boolean {
|
|
416
|
+
if (!Array.isArray(content)) return false
|
|
417
|
+
return content.some(block => {
|
|
418
|
+
if (typeof block !== 'object' || block === null) return false
|
|
419
|
+
const record = block as { type?: unknown; text?: unknown }
|
|
420
|
+
return record.type === 'text' && typeof record.text === 'string' && record.text.trim() !== ''
|
|
421
|
+
})
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
interface FeedbackMarkers {
|
|
426
|
+
readonly renderFingerprints: string[]
|
|
427
|
+
readonly deliveryTurns: number[]
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** 分类当前与 legacy correction message 中的 marker。 */
|
|
431
|
+
function feedbackMarkersIn(text: string): FeedbackMarkers {
|
|
432
|
+
const renderFingerprints: string[] = []
|
|
433
|
+
const deliveryTurns: number[] = []
|
|
241
434
|
let cursor = 0
|
|
242
435
|
while (cursor < text.length) {
|
|
243
436
|
const current = text.indexOf(MARKER_PREFIX, cursor)
|
|
@@ -248,10 +441,23 @@ function markersIn(text: string): string[] {
|
|
|
248
441
|
const index = useLegacy ? legacy : current
|
|
249
442
|
const end = text.indexOf(']', index + prefix.length)
|
|
250
443
|
if (end < 0) break
|
|
251
|
-
|
|
444
|
+
const marker = text.slice(index + prefix.length, end)
|
|
445
|
+
const turn = /^turn-(\d+)$/u.exec(marker)
|
|
446
|
+
if (turn !== null) deliveryTurns.push(Number(turn[1]))
|
|
447
|
+
else renderFingerprints.push(marker)
|
|
252
448
|
cursor = end + 1
|
|
253
449
|
}
|
|
254
|
-
return
|
|
450
|
+
return { renderFingerprints, deliveryTurns }
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/** 恢复一条持久化 correction 消耗的 turn budget。 */
|
|
454
|
+
function accountReplayedCorrection(state: SessionFeedback, turn: number): void {
|
|
455
|
+
if (state.correctionsTurn !== turn) {
|
|
456
|
+
state.correctionsTurn = turn
|
|
457
|
+
state.correctionsThisTurn = 1
|
|
458
|
+
return
|
|
459
|
+
}
|
|
460
|
+
state.correctionsThisTurn = Math.min(MAX_CORRECTIONS_PER_TURN, state.correctionsThisTurn + 1)
|
|
255
461
|
}
|
|
256
462
|
|
|
257
463
|
/** Identify this plugin's source across current and migrated session shapes. */
|
|
@@ -261,18 +467,29 @@ function isFeedbackSource(source: { kind?: unknown; plugin?: unknown } | undefin
|
|
|
261
467
|
}
|
|
262
468
|
|
|
263
469
|
/**
|
|
264
|
-
*
|
|
470
|
+
* 注册 GenUI 回合跟踪、宿主重试判定和可选的围栏修正流程。
|
|
265
471
|
*
|
|
266
|
-
* @param ctx -
|
|
267
|
-
* @param enabled -
|
|
472
|
+
* @param ctx - 宿主 Context。
|
|
473
|
+
* @param enabled - 是否启用同回合围栏修正。
|
|
268
474
|
*/
|
|
269
475
|
export function installFenceFeedback(ctx: Context, enabled: boolean): void {
|
|
270
|
-
if (!enabled) return
|
|
271
476
|
const sessions = new Map<string, SessionFeedback>()
|
|
272
477
|
const stateOf = (sessionId: string): SessionFeedback => {
|
|
273
478
|
let state = sessions.get(sessionId)
|
|
274
479
|
if (state === undefined) {
|
|
275
|
-
state = {
|
|
480
|
+
state = {
|
|
481
|
+
text: '',
|
|
482
|
+
validatedThisTurn: false,
|
|
483
|
+
isSubagent: false,
|
|
484
|
+
deliveredThisTurn: false,
|
|
485
|
+
pendingRenders: new Set(),
|
|
486
|
+
correctedSpec: new Set(),
|
|
487
|
+
deliveryRemindedTurns: new Set(),
|
|
488
|
+
correctionsThisTurn: 0,
|
|
489
|
+
correctionsTurn: undefined,
|
|
490
|
+
currentTurn: undefined,
|
|
491
|
+
accountedCorrectionMessageIds: new Set(),
|
|
492
|
+
}
|
|
276
493
|
sessions.set(sessionId, state)
|
|
277
494
|
}
|
|
278
495
|
return state
|
|
@@ -282,53 +499,155 @@ export function installFenceFeedback(ctx: Context, enabled: boolean): void {
|
|
|
282
499
|
sessions.delete(String(session.id))
|
|
283
500
|
})
|
|
284
501
|
|
|
285
|
-
|
|
502
|
+
const resetTurnState = (state: SessionFeedback, turn: number | undefined): void => {
|
|
503
|
+
state.text = ''
|
|
504
|
+
state.validatedThisTurn = false
|
|
505
|
+
state.deliveredThisTurn = false
|
|
506
|
+
state.pendingRenders.clear()
|
|
507
|
+
state.currentTurn = turn
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
const observeEvent = (session: Session, event: SessionEvent): void => {
|
|
286
511
|
const sessionId = String(session.id)
|
|
512
|
+
if (event.type === 'turn/start') {
|
|
513
|
+
// The formal turn boundary: whatever happened in the previous turn is
|
|
514
|
+
// settled and this turn starts clean. The `user/message` reset below is
|
|
515
|
+
// only a fallback for direct prompts.
|
|
516
|
+
const state = stateOf(sessionId)
|
|
517
|
+
state.isSubagent = session.header.parentSession !== undefined
|
|
518
|
+
// 去重只约束当前回合,新一轮仍可修正相同错误。
|
|
519
|
+
state.correctedSpec.clear()
|
|
520
|
+
resetTurnState(state, (event.data as { turn?: number }).turn)
|
|
521
|
+
return
|
|
522
|
+
}
|
|
287
523
|
if (event.type === 'assistant/message') {
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
524
|
+
const content = (event.data as { message?: { content?: unknown } }).message?.content
|
|
525
|
+
const text = textOfContent(content)
|
|
526
|
+
const state = stateOf(sessionId)
|
|
527
|
+
state.isSubagent = session.header.parentSession !== undefined
|
|
528
|
+
// Fences are read from the BODY only: a draft in the reasoning block is not
|
|
529
|
+
// a delivery and must not become one.
|
|
530
|
+
state.text = extractDshUiFences(text).length > 0 ? text : ''
|
|
531
|
+
// Delivery is sticky for the turn: once a body text exists, the turn has
|
|
532
|
+
// answered and must never be corrected into publishing again. A
|
|
533
|
+
// `render_ui` call in the content proves nothing here — its result does.
|
|
534
|
+
state.deliveredThisTurn = state.deliveredThisTurn || deliveredBodyText(content)
|
|
535
|
+
return
|
|
536
|
+
}
|
|
537
|
+
if (event.type === 'tool/call') {
|
|
538
|
+
const data = event.data as { name?: unknown; callId?: unknown }
|
|
539
|
+
const state = stateOf(sessionId)
|
|
540
|
+
state.isSubagent = session.header.parentSession !== undefined
|
|
541
|
+
// validate_dsh_ui is the FORMAL signal that this turn is GenUI-related.
|
|
542
|
+
if (data.name === 'validate_dsh_ui') state.validatedThisTurn = true
|
|
543
|
+
// The result — not the call — decides whether render_ui delivered.
|
|
544
|
+
if (data.name === DELIVERY_TOOL && typeof data.callId === 'string') {
|
|
545
|
+
state.pendingRenders.add(data.callId)
|
|
546
|
+
}
|
|
547
|
+
return
|
|
548
|
+
}
|
|
549
|
+
if (event.type === 'tool/result') {
|
|
550
|
+
const data = event.data as { message?: unknown; error?: unknown }
|
|
551
|
+
const result = observedToolResult(data.message)
|
|
552
|
+
if (result === null) return
|
|
553
|
+
const state = stateOf(sessionId)
|
|
554
|
+
state.isSubagent = session.header.parentSession !== undefined
|
|
555
|
+
if (!state.pendingRenders.delete(result.callId)) return
|
|
556
|
+
if (data.error === undefined && result.isError !== true && renderResultStatus(result.content) === 'rendered') {
|
|
557
|
+
state.deliveredThisTurn = true
|
|
293
558
|
}
|
|
294
|
-
stateOf(sessionId).text = text
|
|
295
559
|
return
|
|
296
560
|
}
|
|
297
561
|
if (event.type !== 'user/message') return
|
|
298
|
-
const data = event.data as { content?: unknown; source?: { kind?: unknown; plugin?: unknown } }
|
|
562
|
+
const data = event.data as { id?: unknown; content?: unknown; source?: { kind?: unknown; plugin?: unknown } }
|
|
299
563
|
if (isFeedbackSource(data.source)) {
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
const fingerprints = markersIn(textOfContent(data.content))
|
|
303
|
-
if (fingerprints.length === 0) return
|
|
564
|
+
const markers = feedbackMarkersIn(textOfContent(data.content))
|
|
565
|
+
if (markers.renderFingerprints.length === 0 && markers.deliveryTurns.length === 0) return
|
|
304
566
|
const state = stateOf(sessionId)
|
|
305
|
-
|
|
567
|
+
state.isSubagent = session.header.parentSession !== undefined
|
|
568
|
+
for (const fingerprint of markers.renderFingerprints) state.correctedSpec.add(fingerprint)
|
|
569
|
+
for (const turn of markers.deliveryTurns) state.deliveryRemindedTurns.add(turn)
|
|
570
|
+
if (typeof data.id === 'string' && !state.accountedCorrectionMessageIds.has(data.id)) {
|
|
571
|
+
state.accountedCorrectionMessageIds.add(data.id)
|
|
572
|
+
const turn = markers.deliveryTurns[0] ?? state.currentTurn
|
|
573
|
+
if (turn !== undefined) accountReplayedCorrection(state, turn)
|
|
574
|
+
}
|
|
306
575
|
return
|
|
307
576
|
}
|
|
308
|
-
// A
|
|
577
|
+
// A DIRECT human prompt starts a new turn: the previous reply is settled.
|
|
578
|
+
// Synthetic context that rides the same user surface — `agent.inject()`
|
|
579
|
+
// notices, team member messages — carries a different source kind and
|
|
580
|
+
// arrives MID-TURN: resetting here used to wipe the turn's validation and
|
|
581
|
+
// delivery state before the boundary could use it.
|
|
582
|
+
if (data.source?.kind !== 'user') return
|
|
309
583
|
const state = sessions.get(sessionId)
|
|
310
|
-
if (state !== undefined)
|
|
584
|
+
if (state !== undefined) {
|
|
585
|
+
state.isSubagent = session.header.parentSession !== undefined
|
|
586
|
+
// turn/start 先于用户消息;清掉正文状态时保留正式回合身份。
|
|
587
|
+
resetTurnState(state, state.currentTurn)
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const restoreHistory = (session: Session): void => {
|
|
592
|
+
if (sessions.has(String(session.id))) return
|
|
593
|
+
for (const event of session.snapshotEvents()) observeEvent(session, event)
|
|
594
|
+
}
|
|
595
|
+
ctx.on('session/event', (session, event) => {
|
|
596
|
+
// 服务注入是异步的;首条实时事件也先接管历史,避免丢失旧纠错次数。
|
|
597
|
+
restoreHistory(session)
|
|
598
|
+
observeEvent(session, event)
|
|
311
599
|
})
|
|
600
|
+
ctx.on('session/created', restoreHistory)
|
|
601
|
+
const restoreSessions = (sessionCtx: Context): void => {
|
|
602
|
+
const store = sessionCtx.reflect.get('sessions') as SessionStore | undefined
|
|
603
|
+
if (store === undefined) return
|
|
604
|
+
for (const session of store.list()) restoreHistory(session)
|
|
605
|
+
}
|
|
606
|
+
// 宿主不重播历史;安装时同步接管已有会话,异步注入负责晚到的服务。
|
|
607
|
+
restoreSessions(ctx)
|
|
608
|
+
ctx.inject(['sessions'], restoreSessions)
|
|
609
|
+
|
|
610
|
+
ctx.on('llm/stream', (options, next) => {
|
|
611
|
+
if (options.sessionId === undefined || options.purpose !== undefined) return next()
|
|
612
|
+
const state = sessions.get(String(options.sessionId))
|
|
613
|
+
if (state?.validatedThisTurn !== true || state.isSubagent) return next()
|
|
614
|
+
return retryReasoningOnlyGenuiStream(options, next())
|
|
615
|
+
}, { global: true })
|
|
312
616
|
|
|
313
617
|
ctx.on('agent/turn-stopping', ({ agent, turn, signal }): void => {
|
|
618
|
+
if (!enabled) return
|
|
314
619
|
// A child session's fence belongs to a parent reply, and an aborted turn is
|
|
315
620
|
// on its way out: never steer into either.
|
|
316
621
|
if (agent.session.header.parentSession !== undefined) return
|
|
317
622
|
const state = sessions.get(String(agent.session.id))
|
|
318
623
|
if (state === undefined) return
|
|
624
|
+
state.currentTurn = turn
|
|
625
|
+
// A render_ui result still outstanding could deliver (or fail) after this
|
|
626
|
+
// boundary fires: steering now would race the late result, so stay silent
|
|
627
|
+
// and let the next boundary decide on settled facts.
|
|
628
|
+
if (state.pendingRenders.size > 0) return
|
|
629
|
+
const usedThisTurn = state.correctionsTurn === turn ? state.correctionsThisTurn : 0
|
|
319
630
|
const plan = planFenceFeedback({
|
|
320
631
|
text: state.text,
|
|
321
632
|
turn,
|
|
322
|
-
|
|
323
|
-
|
|
633
|
+
correctedSpec: state.correctedSpec,
|
|
634
|
+
deliveryRemindedTurns: state.deliveryRemindedTurns,
|
|
635
|
+
validatedThisTurn: state.validatedThisTurn,
|
|
636
|
+
deliveredThisTurn: state.deliveredThisTurn,
|
|
324
637
|
aborted: signal.aborted,
|
|
638
|
+
correctionsThisTurn: usedThisTurn,
|
|
639
|
+
correctionsTurn: state.correctionsTurn,
|
|
325
640
|
})
|
|
326
641
|
if (plan === null) return
|
|
327
642
|
// Account BEFORE sending: a re-entrant boundary must not deliver twice.
|
|
328
|
-
for (const fingerprint of plan.fingerprints) state.
|
|
329
|
-
|
|
643
|
+
for (const fingerprint of plan.fingerprints) state.correctedSpec.add(fingerprint)
|
|
644
|
+
if (plan.kind === 'delivery') state.deliveryRemindedTurns.add(plan.turn)
|
|
645
|
+
state.correctionsTurn = plan.turn
|
|
646
|
+
state.correctionsThisTurn = usedThisTurn + 1
|
|
330
647
|
try {
|
|
331
|
-
|
|
648
|
+
const message = createFeedbackMessage(plan.text, agent.session.header.version)
|
|
649
|
+
state.accountedCorrectionMessageIds.add(message.id)
|
|
650
|
+
agent.steer(message)
|
|
332
651
|
} catch (error) {
|
|
333
652
|
ctx.logger?.warn?.(`dsh-genui: fence feedback steering failed (${error instanceof Error ? error.message : String(error)})`)
|
|
334
653
|
}
|
package/src/plugin/index.ts
CHANGED
|
@@ -119,12 +119,13 @@ Rules:
|
|
|
119
119
|
- JSON 严格:坏组件被丢弃,坏围栏变代码块;≥3 节点或含 table 时调 validate_dsh_ui,按诊断修改并重验;小围栏字段存疑也先验证。
|
|
120
120
|
- warning=block_markdown:按 replacement 改写并重验。
|
|
121
121
|
- 规模: ≤200 节点、嵌套≤8 层(超出被截断);一条回答 3–8 个组件,一个主题一个主组件;3D mesh 1–5;plot 给合理 xMin/xMax。
|
|
122
|
-
- LOCAL-FIRST + actions: UI 能自己做的状态变化(判卷、判题、重置、展开、选中)就地完成,零往返;action
|
|
123
|
-
- Durable state:
|
|
122
|
+
- LOCAL-FIRST + actions: UI 能自己做的状态变化(判卷、判题、重置、展开、选中)就地完成,零往返;action 只用于必须模型参与的事。交互以 [genui-action] name + 组件数据回传,届时重渲染更新 UI;无 action 的按钮禁用。
|
|
123
|
+
- Durable state: 交互状态按「会话+内容指纹」持久化——刷新/重放恢复;相同内容保留,新内容重置。
|
|
124
124
|
- 卷子模式: 每题一个 radio(group+answer+explanation)+ 一个 submit(groups 全列),本地判分。
|
|
125
125
|
- Secrets ban: 不索取密码、API Key、Token、恢复码;需要时拒绝并解释。
|
|
126
|
-
- Tool channel: render_ui 工具把同一 spec
|
|
127
|
-
-
|
|
126
|
+
- Tool channel: render_ui 工具把同一 spec 渲染为工具行卡片;围栏用于回答内联 UI。
|
|
127
|
+
- 围栏位置:\`dsh-ui\` 只写在**回答正文**;写在 reasoning/思考块里不渲染、用户看不到——思考里验证好 spec,正文再输出同一份。
|
|
128
|
+
- Panel: "panel":true 只渲染进会话面板 dock 并原地更新;"append":true 追加合并;上限 200 节点/200 次追加,满了发 replace 重建。面板来的 [genui-action] 只回一个 panel:true 围栏 + 至多一行 10 字内确认。`
|
|
128
129
|
|
|
129
130
|
/**
|
|
130
131
|
* Register the GenUI output-language section and the render_ui tool.
|
|
@@ -184,8 +185,8 @@ function bundledSkillProvider(): SkillProvider {
|
|
|
184
185
|
export interface GenuiPluginConfig {
|
|
185
186
|
/**
|
|
186
187
|
* 在最终 dsh-ui 围栏无法渲染的回合中请求模型发送一次修正版(issue #160)。
|
|
187
|
-
* 默认开启,设置为 false
|
|
188
|
-
*
|
|
188
|
+
* 默认开启,设置为 false 可以关闭同回合围栏修正。GenUI 回合的 reasoning-only 响应仍会交给宿主重试策略处理。
|
|
189
|
+
* 每回合和每个围栏正文最多请求一次,子代理不触发,每次修正请求会消耗模型步数。
|
|
189
190
|
*/
|
|
190
191
|
fenceFeedback?: boolean
|
|
191
192
|
}
|