@changfenhuang/dsh-genui 0.11.3 → 0.12.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/CHANGELOG.md +44 -0
- package/README.md +21 -6
- package/README.zh-CN.md +21 -6
- package/SKILL.md +18 -6
- package/lib/assets/mermaid.js +325 -325
- package/lib/assets/standalone-runtime.js +10 -9
- package/lib/client.js +31 -33
- package/lib/index.js +1060 -371
- package/lib/types/client/blocks/forms.d.ts +5 -6
- package/lib/types/client/blocks/state.d.ts +2 -11
- package/lib/types/client/dom-fence.d.ts +2 -1
- package/lib/types/client/file-address.d.ts +4 -0
- package/lib/types/client/file-link-context.d.ts +41 -0
- package/lib/types/client/file-link.d.ts +17 -0
- package/lib/types/client/i18n/en.d.ts +5 -1
- package/lib/types/client/inline.d.ts +1 -1
- package/lib/types/client/mermaid-colors.d.ts +2 -0
- package/lib/types/client/panel.d.ts +6 -1
- 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 +55 -12
- package/lib/types/plugin/index.d.ts +7 -3
- package/lib/types/shared/fence-repair.d.ts +19 -7
- package/package.json +1 -12
- package/src/client/GenuiBlock.module.css +100 -4
- package/src/client/GenuiBlock.tsx +7 -14
- package/src/client/artifact/ArtifactExport.module.css +34 -3
- package/src/client/artifact/ExportableGenuiBlock.tsx +23 -2
- package/src/client/blocks/charts.tsx +63 -14
- package/src/client/blocks/forms.tsx +26 -48
- package/src/client/blocks/render-node.tsx +11 -8
- package/src/client/blocks/state.ts +2 -12
- package/src/client/dom-fence.tsx +190 -50
- package/src/client/file-address.ts +37 -0
- package/src/client/file-link-context.tsx +114 -0
- package/src/client/file-link.ts +46 -0
- package/src/client/genui-runtime/diagnostics.ts +20 -0
- package/src/client/genui-runtime/normalize.ts +28 -9
- package/src/client/guard.ts +98 -153
- package/src/client/i18n/en.ts +5 -1
- package/src/client/i18n/zh.ts +5 -1
- package/src/client/index.tsx +23 -5
- package/src/client/inline.ts +28 -10
- package/src/client/mermaid-colors.ts +39 -0
- package/src/client/mermaid-core.ts +7 -3
- package/src/client/panel.tsx +18 -5
- 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 +384 -53
- package/src/plugin/index.ts +20 -9
- package/src/plugin/tool.ts +8 -2
- package/src/shared/fence-repair.ts +345 -128
- package/tsdown.config.ts +3 -7
- package/lib/invariant.js +0 -19
- package/lib/types/plugin/invariant.d.ts +0 -15
- package/src/plugin/invariant.ts +0 -30
package/src/plugin/index.ts
CHANGED
|
@@ -83,6 +83,8 @@ async function serveGenuiAsset(req: IncomingMessage, res: ServerResponse): Promi
|
|
|
83
83
|
}
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
const RENDER_UI_TOOL_GUIDANCE = '- Tool channel: render_ui 工具把同一 spec 渲染为工具行卡片;围栏用于回答内联 UI。'
|
|
87
|
+
|
|
86
88
|
/** The fence language description injected into every assembled system prompt.
|
|
87
89
|
* Deliberately slim: the `genui` skill carries the full component→field
|
|
88
90
|
* mapping; this section keeps only the contract that must always be
|
|
@@ -119,12 +121,13 @@ Rules:
|
|
|
119
121
|
- JSON 严格:坏组件被丢弃,坏围栏变代码块;≥3 节点或含 table 时调 validate_dsh_ui,按诊断修改并重验;小围栏字段存疑也先验证。
|
|
120
122
|
- warning=block_markdown:按 replacement 改写并重验。
|
|
121
123
|
- 规模: ≤200 节点、嵌套≤8 层(超出被截断);一条回答 3–8 个组件,一个主题一个主组件;3D mesh 1–5;plot 给合理 xMin/xMax。
|
|
122
|
-
- LOCAL-FIRST + actions: UI 能自己做的状态变化(判卷、判题、重置、展开、选中)就地完成,零往返;action
|
|
123
|
-
- Durable state:
|
|
124
|
+
- LOCAL-FIRST + actions: UI 能自己做的状态变化(判卷、判题、重置、展开、选中)就地完成,零往返;action 只用于必须模型参与的事。交互以 [genui-action] name + 组件数据回传,届时重渲染更新 UI;无 action 的按钮禁用。
|
|
125
|
+
- Durable state: 交互状态按「会话+内容指纹」持久化——刷新/重放恢复;相同内容保留,新内容重置。
|
|
124
126
|
- 卷子模式: 每题一个 radio(group+answer+explanation)+ 一个 submit(groups 全列),本地判分。
|
|
125
127
|
- Secrets ban: 不索取密码、API Key、Token、恢复码;需要时拒绝并解释。
|
|
126
|
-
|
|
127
|
-
-
|
|
128
|
+
${RENDER_UI_TOOL_GUIDANCE}
|
|
129
|
+
- 围栏位置:\`dsh-ui\` 只写在**回答正文**;写在 reasoning/思考块里不渲染、用户看不到——思考里验证好 spec,正文再输出同一份。
|
|
130
|
+
- Panel: "panel":true 只渲染进会话面板 dock 并原地更新;"append":true 追加合并;上限 200 节点/200 次追加,满了发 replace 重建。面板来的 [genui-action] 只回一个 panel:true 围栏 + 至多一行 10 字内确认。`
|
|
128
131
|
|
|
129
132
|
/**
|
|
130
133
|
* Register the GenUI output-language section and the render_ui tool.
|
|
@@ -184,25 +187,33 @@ function bundledSkillProvider(): SkillProvider {
|
|
|
184
187
|
export interface GenuiPluginConfig {
|
|
185
188
|
/**
|
|
186
189
|
* 在最终 dsh-ui 围栏无法渲染的回合中请求模型发送一次修正版(issue #160)。
|
|
187
|
-
* 默认开启,设置为 false
|
|
188
|
-
*
|
|
190
|
+
* 默认开启,设置为 false 可以关闭同回合围栏修正。GenUI 回合的 reasoning-only 响应仍会交给宿主重试策略处理。
|
|
191
|
+
* 每回合和每个围栏正文最多请求一次,子代理不触发,每次修正请求会消耗模型步数。
|
|
189
192
|
*/
|
|
190
193
|
fenceFeedback?: boolean
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* 是否注册 render_ui 模型工具。默认开启,设置为 false 时关闭;不影响 validate_dsh_ui 和 dsh-ui 围栏渲染。
|
|
197
|
+
*/
|
|
198
|
+
renderUiTool?: boolean
|
|
191
199
|
}
|
|
192
200
|
|
|
193
201
|
export function apply(ctx: Context, config?: GenuiPluginConfig): void {
|
|
202
|
+
const renderUiToolEnabled = config?.renderUiTool !== false
|
|
194
203
|
ctx.systemPrompt.section({
|
|
195
204
|
name: 'genui:fence',
|
|
196
205
|
order: ctx.systemPrompt.getSectionOrder('STRUCTURED_OUTPUT'),
|
|
197
|
-
text: GENUI_SECTION_TEXT,
|
|
206
|
+
text: renderUiToolEnabled ? GENUI_SECTION_TEXT : GENUI_SECTION_TEXT.replace(`${RENDER_UI_TOOL_GUIDANCE}\n`, ''),
|
|
198
207
|
})
|
|
199
|
-
installFenceFeedback(ctx, config?.fenceFeedback !== false)
|
|
208
|
+
installFenceFeedback(ctx, config?.fenceFeedback !== false, renderUiToolEnabled)
|
|
200
209
|
// Hosts without tool access keep the fence channel. The dependency fiber
|
|
201
210
|
// starts whenever tools becomes available and unloads its registrations
|
|
202
211
|
// before either the service or this plugin is replaced.
|
|
203
212
|
ctx.inject(['tools'], (toolsCtx) => {
|
|
204
213
|
toolsCtx.effect(function* () {
|
|
205
|
-
|
|
214
|
+
if (renderUiToolEnabled) {
|
|
215
|
+
yield toolsCtx.tools.register(createRenderUiTool())
|
|
216
|
+
}
|
|
206
217
|
yield toolsCtx.tools.register(createValidateDshUiTool())
|
|
207
218
|
}, 'dsh-genui: model tools')
|
|
208
219
|
})
|
package/src/plugin/tool.ts
CHANGED
|
@@ -162,6 +162,9 @@ function formatProcessWarnings(processed: GenuiProcessResult): string[] {
|
|
|
162
162
|
? `warning=alias_ignored path=${warning.path} canonical=${canonicalPath}`
|
|
163
163
|
: `warning=alias_normalized path=${warning.path} canonical=${canonicalPath}`
|
|
164
164
|
}
|
|
165
|
+
if (warning.kind === 'unknown-field') {
|
|
166
|
+
return `warning=unknown_field path=${JSON.stringify(warning.path)} detail=${JSON.stringify(warning.message)}`
|
|
167
|
+
}
|
|
165
168
|
return `warning=process detail=${JSON.stringify(warning.message)}`
|
|
166
169
|
})
|
|
167
170
|
}
|
|
@@ -260,6 +263,7 @@ const VALIDATE_DESCRIPTION =
|
|
|
260
263
|
+ 'Pass the exact JSON text you are about to put inside the fence as the "spec" argument (a string). '
|
|
261
264
|
+ 'Returns a [genui-validation] protocol block with status, diagnostics, next action, and reply_language=conversation. '
|
|
262
265
|
+ 'When invalid JSON is repairable, repaired_json contains the fixed body; emit it only with next=emit_repaired_fence. '
|
|
266
|
+
+ 'A warning=unknown_field requires fixing the indicated path and another validation call. '
|
|
263
267
|
+ 'A warning=block_markdown requires the indicated replacement node and another validation call.'
|
|
264
268
|
|
|
265
269
|
const VALIDATE_PARAMETERS: Record<string, unknown> = {
|
|
@@ -368,6 +372,7 @@ export function createValidateDshUiTool(): ToolDefinition {
|
|
|
368
372
|
if (processed.spec !== null && processed.errors.length === 0) {
|
|
369
373
|
const warnings = formatProcessWarnings(processed)
|
|
370
374
|
const inlineWarnings = formatInlineContentWarnings(processed.spec)
|
|
375
|
+
const requiresCorrection = inlineWarnings.length > 0 || processed.warnings.some(warning => warning.kind === 'unknown-field')
|
|
371
376
|
return `${validationProtocol([
|
|
372
377
|
'status=invalid',
|
|
373
378
|
'error=invalid_json',
|
|
@@ -377,7 +382,7 @@ export function createValidateDshUiTool(): ToolDefinition {
|
|
|
377
382
|
...inlineWarnings,
|
|
378
383
|
'repair=applied',
|
|
379
384
|
`repair_count=${repaired.repairs}`,
|
|
380
|
-
|
|
385
|
+
requiresCorrection ? 'next=fix_and_revalidate' : 'next=emit_repaired_fence',
|
|
381
386
|
])}\nrepaired_json:\n\`\`\`\n${repaired.text}\n\`\`\``
|
|
382
387
|
}
|
|
383
388
|
}
|
|
@@ -409,9 +414,10 @@ export function createValidateDshUiTool(): ToolDefinition {
|
|
|
409
414
|
}
|
|
410
415
|
const warnings = formatProcessWarnings(processed)
|
|
411
416
|
const inlineWarnings = formatInlineContentWarnings(processed.spec)
|
|
417
|
+
const requiresCorrection = inlineWarnings.length > 0 || processed.warnings.some(warning => warning.kind === 'unknown-field')
|
|
412
418
|
return validationProtocol([
|
|
413
419
|
'status=valid', `rendered=${processed.renderedCount}`, ...warnings, ...inlineWarnings,
|
|
414
|
-
|
|
420
|
+
requiresCorrection ? 'next=fix_and_revalidate' : 'next=emit_fence',
|
|
415
421
|
])
|
|
416
422
|
},
|
|
417
423
|
presentCall(): GenericCallView | undefined {
|
|
@@ -51,12 +51,12 @@ export function describeJsonFailure(raw: string): string | null {
|
|
|
51
51
|
* with ASCII quotes (e.g. `对"别名路径"判定失败`), which makes JSON.parse
|
|
52
52
|
* fail near that quote with "Expected ',' or ']'...".
|
|
53
53
|
* 2. Trailing commas before `}` / `]` or at end of input.
|
|
54
|
+
* 3. Valueless object keys immediately before a complete key-and-colon pair.
|
|
54
55
|
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
* comma → drop it.
|
|
56
|
+
* A shared grammar-aware scan distinguishes object keys from values and
|
|
57
|
+
* checks the continuation after a potential string terminator. Ambiguous
|
|
58
|
+
* value quotes use bounded backtracking; trailing commas are dropped only
|
|
59
|
+
* outside strings.
|
|
60
60
|
*
|
|
61
61
|
* Returns `{ text, repairs }` on success, or null when nothing needed fixing
|
|
62
62
|
* or the body still does not parse (callers fall through to tier-2 / banner).
|
|
@@ -68,61 +68,370 @@ export function repairFenceJson(raw: string): { text: string; repairs: number }
|
|
|
68
68
|
} catch {
|
|
69
69
|
// fall through to the repair scan
|
|
70
70
|
}
|
|
71
|
+
return scanFenceJson(raw, false)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** A fixed search budget prevents quote ambiguity from becoming exponential. */
|
|
75
|
+
const MAX_QUOTE_ATTEMPTS = 32
|
|
76
|
+
const MAX_QUOTE_LOOKAHEAD = 4096
|
|
77
|
+
|
|
78
|
+
type JsonScope = {
|
|
79
|
+
closer: '}' | ']'
|
|
80
|
+
expecting: 'key' | 'colon' | 'value' | 'comma'
|
|
81
|
+
}
|
|
82
|
+
type RepairScan = {
|
|
83
|
+
text: string
|
|
84
|
+
repairs: number
|
|
85
|
+
rootEnd: number
|
|
86
|
+
rawRootEnd: number
|
|
87
|
+
choices: number[]
|
|
88
|
+
unfinishedString: boolean
|
|
89
|
+
invalidValue: boolean
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isJsonSpace(ch: string | undefined): boolean {
|
|
93
|
+
return ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r'
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Skip only complete, valueless object keys followed by a valued member. */
|
|
97
|
+
function skipValuelessKeys(raw: string, start: number, limit = raw.length): { start: number; repairs: number } | null {
|
|
98
|
+
let cursor = start
|
|
99
|
+
let repairs = 0
|
|
100
|
+
while (cursor < limit && raw[cursor] === '"') {
|
|
101
|
+
const keyStart = cursor++
|
|
102
|
+
while (cursor < limit && raw[cursor] !== '"') {
|
|
103
|
+
if (raw[cursor] === '\\') cursor += 2
|
|
104
|
+
else cursor++
|
|
105
|
+
}
|
|
106
|
+
if (cursor >= limit) return null
|
|
107
|
+
cursor++
|
|
108
|
+
try { JSON.parse(raw.slice(keyStart, cursor)) } catch { return null }
|
|
109
|
+
while (cursor < limit && isJsonSpace(raw[cursor])) cursor++
|
|
110
|
+
if (raw[cursor] === ':') return repairs > 0 ? { start: keyStart, repairs } : null
|
|
111
|
+
if (raw[cursor] !== ',') return null
|
|
112
|
+
repairs++
|
|
113
|
+
cursor++
|
|
114
|
+
while (cursor < limit && isJsonSpace(raw[cursor])) cursor++
|
|
115
|
+
}
|
|
116
|
+
return null
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Quote closure is contextual: only keys may be followed by `:`, and a comma
|
|
121
|
+
* must introduce the next member of the enclosing object/array. Walk closing
|
|
122
|
+
* delimiters too, so a bracket in a quoted code example cannot end a value
|
|
123
|
+
* when prose immediately follows it inside the enclosing JSON structure.
|
|
124
|
+
* Lookahead has a fixed bound; inconclusive long continuations keep the
|
|
125
|
+
* terminator interpretation and leave the final JSON.parse as the arbiter.
|
|
126
|
+
*/
|
|
127
|
+
function quoteCanClose(raw: string, index: number, key: boolean, scopes: JsonScope[], complete: boolean): boolean {
|
|
128
|
+
const limit = Math.min(raw.length, index + MAX_QUOTE_LOOKAHEAD)
|
|
129
|
+
let cursor = index + 1
|
|
130
|
+
const skipSpace = (): void => { while (cursor < limit && isJsonSpace(raw[cursor])) cursor++ }
|
|
131
|
+
skipSpace()
|
|
132
|
+
if (cursor >= limit) return true
|
|
133
|
+
if (key) return raw[cursor] === ':'
|
|
134
|
+
let scopeIndex = scopes.length - 1
|
|
135
|
+
if (raw[cursor] === ':') return false
|
|
136
|
+
while (raw[cursor] === '}' || raw[cursor] === ']') {
|
|
137
|
+
const scope = scopes[scopeIndex]
|
|
138
|
+
if (scope === undefined) return false
|
|
139
|
+
if (raw[cursor] === scope.closer) scopeIndex--
|
|
140
|
+
else if (!complete) return false
|
|
141
|
+
cursor++
|
|
142
|
+
skipSpace()
|
|
143
|
+
if (cursor >= limit) return true
|
|
144
|
+
// Tier-2 can retain a complete root prefix followed by junk. It tries
|
|
145
|
+
// whole-body quote alternatives before actually adopting that prefix.
|
|
146
|
+
if (scopeIndex < 0) return complete
|
|
147
|
+
}
|
|
148
|
+
if (raw[cursor] !== ',') return false
|
|
149
|
+
const scope = scopes[scopeIndex]
|
|
150
|
+
if (scope === undefined) return false
|
|
151
|
+
cursor++
|
|
152
|
+
skipSpace()
|
|
153
|
+
if (cursor >= limit) return true
|
|
154
|
+
if (raw[cursor] === scope.closer) return true // a removable trailing comma
|
|
155
|
+
if (scope.closer === ']') return /["[{tfn\-0-9]/.test(raw[cursor]!)
|
|
156
|
+
if (raw[cursor] !== '"') return false
|
|
157
|
+
// A valueless sibling is removable; it must not make a real value's
|
|
158
|
+
// closing quote absorb that sibling into the preceding string.
|
|
159
|
+
const valueless = skipValuelessKeys(raw, cursor, limit)
|
|
160
|
+
if (valueless !== null) cursor = valueless.start
|
|
161
|
+
// An object comma must be followed by a quoted key and its colon. Escapes
|
|
162
|
+
// in that key are skipped without changing them.
|
|
163
|
+
cursor++
|
|
164
|
+
while (cursor < limit) {
|
|
165
|
+
if (raw[cursor] === '\\') { cursor += 2; continue }
|
|
166
|
+
if (raw[cursor] === '"') {
|
|
167
|
+
cursor++
|
|
168
|
+
skipSpace()
|
|
169
|
+
return cursor >= limit || raw[cursor] === ':'
|
|
170
|
+
}
|
|
171
|
+
if (raw[cursor]!.charCodeAt(0) < 0x20) return false
|
|
172
|
+
cursor++
|
|
173
|
+
}
|
|
174
|
+
return true
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Both tiers share the same string-aware scan and quote decisions. */
|
|
178
|
+
function scanFenceCandidate(raw: string, complete: boolean, contentQuotes: ReadonlySet<number>): RepairScan {
|
|
71
179
|
let out = ''
|
|
180
|
+
const scopes: JsonScope[] = []
|
|
72
181
|
let inString = false
|
|
182
|
+
let key = false
|
|
183
|
+
let interiorQuote = false
|
|
73
184
|
let escaped = false
|
|
74
185
|
let repairs = 0
|
|
186
|
+
let rootEnd = 0
|
|
187
|
+
let rawRootEnd = 0
|
|
188
|
+
let invalidValue = false
|
|
189
|
+
const choices: number[] = []
|
|
190
|
+
const finishValue = (): void => {
|
|
191
|
+
const scope = scopes[scopes.length - 1]
|
|
192
|
+
if (scope !== undefined) scope.expecting = 'comma'
|
|
193
|
+
}
|
|
75
194
|
for (let i = 0; i < raw.length; i++) {
|
|
76
|
-
const ch = raw[i]
|
|
195
|
+
const ch = raw[i]!
|
|
77
196
|
if (escaped) {
|
|
197
|
+
if (inString && ch === '"') interiorQuote = true
|
|
78
198
|
out += ch
|
|
79
199
|
escaped = false
|
|
80
200
|
continue
|
|
81
201
|
}
|
|
82
|
-
if (inString
|
|
83
|
-
out += ch
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
// Inside a string: is this quote the terminator? Look past whitespace.
|
|
94
|
-
let j = i + 1
|
|
95
|
-
while (j < raw.length && (raw[j] === ' ' || raw[j] === '\t' || raw[j] === '\n' || raw[j] === '\r')) j++
|
|
96
|
-
const next = j < raw.length ? raw[j] : ''
|
|
97
|
-
if (next === ',' || next === ']' || next === '}' || next === ':' || next === '') {
|
|
202
|
+
if (inString) {
|
|
203
|
+
if (ch === '\\') { out += ch; escaped = true; continue }
|
|
204
|
+
if (ch !== '"') { out += ch; continue }
|
|
205
|
+
let next = i + 1
|
|
206
|
+
while (next < raw.length && isJsonSpace(raw[next])) next++
|
|
207
|
+
// Without any interior quote, a delimiter-looking quote is a normal
|
|
208
|
+
// terminator even if its sibling is malformed. Do not absorb a broken
|
|
209
|
+
// bare value merely to make structural completion succeed.
|
|
210
|
+
const ordinaryEnd = !key && ((!interiorQuote && ',}]:'.includes(raw[next] ?? '\0'))
|
|
211
|
+
|| (scopes[scopes.length - 1]?.closer === ']' && raw[next] === ','))
|
|
212
|
+
if (!contentQuotes.has(i) && (ordinaryEnd || quoteCanClose(raw, i, key, scopes, complete))) {
|
|
98
213
|
inString = false
|
|
99
214
|
out += ch
|
|
215
|
+
const scope = scopes[scopes.length - 1]
|
|
216
|
+
if (key) {
|
|
217
|
+
if (scope !== undefined) scope.expecting = 'colon'
|
|
218
|
+
} else {
|
|
219
|
+
finishValue()
|
|
220
|
+
// Only value terminators create alternatives. Retaining at most
|
|
221
|
+
// 32 recent decisions bounds the pending search and its memory.
|
|
222
|
+
if (scope !== undefined && interiorQuote) {
|
|
223
|
+
if (choices.length === MAX_QUOTE_ATTEMPTS) choices.shift()
|
|
224
|
+
choices.push(i)
|
|
225
|
+
}
|
|
226
|
+
}
|
|
100
227
|
} else {
|
|
101
|
-
// Free-standing quote inside a value → escape it.
|
|
102
228
|
out += '\\"'
|
|
229
|
+
interiorQuote = true
|
|
103
230
|
repairs++
|
|
104
231
|
}
|
|
105
232
|
continue
|
|
106
233
|
}
|
|
234
|
+
if (ch === '"') {
|
|
235
|
+
key = scopes[scopes.length - 1]?.expecting === 'key'
|
|
236
|
+
if (key) {
|
|
237
|
+
const valueless = skipValuelessKeys(raw, i)
|
|
238
|
+
if (valueless !== null) {
|
|
239
|
+
repairs += valueless.repairs
|
|
240
|
+
i = valueless.start - 1
|
|
241
|
+
continue
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
interiorQuote = false
|
|
245
|
+
inString = true
|
|
246
|
+
out += ch
|
|
247
|
+
continue
|
|
248
|
+
}
|
|
249
|
+
if (ch === '{' || ch === '[') {
|
|
250
|
+
finishValue()
|
|
251
|
+
scopes.push({ closer: ch === '{' ? '}' : ']', expecting: ch === '{' ? 'key' : 'value' })
|
|
252
|
+
out += ch
|
|
253
|
+
continue
|
|
254
|
+
}
|
|
255
|
+
if (ch === '}' || ch === ']') {
|
|
256
|
+
if (scopes[scopes.length - 1]?.closer === ch) {
|
|
257
|
+
scopes.pop()
|
|
258
|
+
out += ch
|
|
259
|
+
if (scopes.length === 0 && rootEnd === 0) {
|
|
260
|
+
rootEnd = out.length
|
|
261
|
+
rawRootEnd = i + 1
|
|
262
|
+
}
|
|
263
|
+
} else if (complete) repairs++
|
|
264
|
+
else out += ch
|
|
265
|
+
continue
|
|
266
|
+
}
|
|
107
267
|
if (ch === ',') {
|
|
108
|
-
// Trailing comma before `}` / `]` / end of input → drop it.
|
|
109
268
|
let j = i + 1
|
|
110
|
-
while (j < raw.length && (raw[j]
|
|
111
|
-
|
|
112
|
-
if (next === '}' || next === ']' || next === '') {
|
|
269
|
+
while (j < raw.length && isJsonSpace(raw[j])) j++
|
|
270
|
+
if (j === raw.length || raw[j] === '}' || raw[j] === ']') {
|
|
113
271
|
repairs++
|
|
114
272
|
continue
|
|
115
273
|
}
|
|
274
|
+
const scope = scopes[scopes.length - 1]
|
|
275
|
+
if (scope !== undefined) scope.expecting = scope.closer === '}' ? 'key' : 'value'
|
|
276
|
+
} else if (ch === ':') {
|
|
277
|
+
const scope = scopes[scopes.length - 1]
|
|
278
|
+
if (scope !== undefined) scope.expecting = 'value'
|
|
279
|
+
} else if (!isJsonSpace(ch) && scopes[scopes.length - 1]?.expecting === 'value') {
|
|
280
|
+
// Invalid bare values are not quote ambiguity: do not turn an earlier
|
|
281
|
+
// finished string into a container for `broken` / `undefined` tokens.
|
|
282
|
+
let j = i + 1
|
|
283
|
+
while (j < raw.length && !isJsonSpace(raw[j]) && !'{}[],:"'.includes(raw[j]!)) j++
|
|
284
|
+
const token = raw.slice(i, j)
|
|
285
|
+
if (!/^(?:true|false|null|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)$/.test(token)) invalidValue = true
|
|
286
|
+
finishValue()
|
|
287
|
+
out += token
|
|
288
|
+
i = j - 1
|
|
289
|
+
continue
|
|
116
290
|
}
|
|
117
291
|
out += ch
|
|
118
292
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
|
|
293
|
+
const unfinishedString = inString
|
|
294
|
+
if (complete) {
|
|
295
|
+
if (inString) { out += '"'; repairs++ }
|
|
296
|
+
while (scopes.length > 0) { out += scopes.pop()!.closer; repairs++ }
|
|
297
|
+
}
|
|
298
|
+
return { text: out, repairs, rootEnd, rawRootEnd, choices, unfinishedString, invalidValue }
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Find an authoritative, already-valid object/array prefix without repair. */
|
|
302
|
+
function originalJsonPrefix(raw: string): string | null {
|
|
303
|
+
const scopes: string[] = []
|
|
304
|
+
let inString = false
|
|
305
|
+
let escaped = false
|
|
306
|
+
let started = false
|
|
307
|
+
for (let i = 0; i < raw.length; i++) {
|
|
308
|
+
const ch = raw[i]!
|
|
309
|
+
if (escaped) { escaped = false; continue }
|
|
310
|
+
if (inString) {
|
|
311
|
+
if (ch === '\\') escaped = true
|
|
312
|
+
else if (ch === '"') inString = false
|
|
313
|
+
continue
|
|
314
|
+
}
|
|
315
|
+
if (!started) {
|
|
316
|
+
if (isJsonSpace(ch)) continue
|
|
317
|
+
if (ch !== '{' && ch !== '[') return null
|
|
318
|
+
started = true
|
|
319
|
+
}
|
|
320
|
+
if (ch === '"') { inString = true; continue }
|
|
321
|
+
if (ch === '{' || ch === '[') scopes.push(ch === '{' ? '}' : ']')
|
|
322
|
+
else if (ch === '}' || ch === ']') {
|
|
323
|
+
if (scopes.pop() !== ch) return null
|
|
324
|
+
if (scopes.length === 0) {
|
|
325
|
+
const text = raw.slice(0, i + 1).trimEnd()
|
|
326
|
+
try { JSON.parse(text); return text } catch { return null }
|
|
327
|
+
}
|
|
328
|
+
}
|
|
125
329
|
}
|
|
330
|
+
return null
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Prefer real terminators; if the resulting whole body fails, try interpreting
|
|
335
|
+
* a bounded number of ambiguous value quotes as content. Each attempt is an
|
|
336
|
+
* iterative scan, not a recursive parser; no engine-specific error offset is
|
|
337
|
+
* needed. Fixed attempts/lookahead give O(n) time and O(n) working storage.
|
|
338
|
+
*/
|
|
339
|
+
function scanFenceJson(raw: string, complete: boolean): { text: string; repairs: number } | null {
|
|
340
|
+
// A genuine original root is authoritative. Never reinterpret its closing
|
|
341
|
+
// quote to absorb a second object or prose, even during speculative repair.
|
|
342
|
+
const original = originalJsonPrefix(raw)
|
|
343
|
+
if (original !== null) return complete ? { text: original, repairs: 1 } : null
|
|
344
|
+
const pending: number[][] = [[]]
|
|
345
|
+
let prefix: { text: string; repairs: number; rawEnd: number } | null = null
|
|
346
|
+
for (let attempt = 0; attempt < MAX_QUOTE_ATTEMPTS && pending.length > 0; attempt++) {
|
|
347
|
+
const forced = pending.pop()!
|
|
348
|
+
const scanned = scanFenceCandidate(raw, complete, new Set(forced))
|
|
349
|
+
// A speculative branch must find a real closing quote. Appending one to
|
|
350
|
+
// that branch could swallow malformed sibling fields into invented text.
|
|
351
|
+
if (scanned.repairs > 0 && !(forced.length > 0 && scanned.unfinishedString)) {
|
|
352
|
+
try {
|
|
353
|
+
JSON.parse(scanned.text)
|
|
354
|
+
return { text: scanned.text, repairs: scanned.repairs }
|
|
355
|
+
} catch { /* try a different bounded quote interpretation */ }
|
|
356
|
+
}
|
|
357
|
+
// Validate possible prefixes now, but do not adopt one until all whole-
|
|
358
|
+
// body alternatives have failed. Prefer the one that retains the most
|
|
359
|
+
// source text, rather than an earlier quote mistaken for a root end.
|
|
360
|
+
if (complete && scanned.rootEnd > 0 && (prefix === null || scanned.rawRootEnd > prefix.rawEnd)) {
|
|
361
|
+
const text = scanned.text.slice(0, scanned.rootEnd).trimEnd()
|
|
362
|
+
try {
|
|
363
|
+
JSON.parse(text)
|
|
364
|
+
prefix = { text, repairs: scanned.repairs + 1, rawEnd: scanned.rawRootEnd }
|
|
365
|
+
} catch { /* a balanced but invalid prefix cannot be adopted */ }
|
|
366
|
+
}
|
|
367
|
+
if (scanned.invalidValue) continue
|
|
368
|
+
const last = forced[forced.length - 1] ?? -1
|
|
369
|
+
for (const quote of scanned.choices) {
|
|
370
|
+
if (quote > last) pending.push([...forced, quote])
|
|
371
|
+
}
|
|
372
|
+
// Newer decisions are tried first. Discard older pending branches when
|
|
373
|
+
// the fixed budget is full instead of accumulating an exponential tree.
|
|
374
|
+
if (pending.length > MAX_QUOTE_ATTEMPTS) pending.splice(0, pending.length - MAX_QUOTE_ATTEMPTS)
|
|
375
|
+
}
|
|
376
|
+
// Balanced-prefix adoption remains strictly tier-2 / settled-only, and only
|
|
377
|
+
// after whole-body repair attempts have failed.
|
|
378
|
+
if (prefix !== null) return { text: prefix.text, repairs: prefix.repairs }
|
|
379
|
+
return null
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* 取**第一个平衡根值**的文本(丢弃其后的杂字符);没有平衡根时返回 null。
|
|
384
|
+
*
|
|
385
|
+
* 与 {@link completeFenceJson} 里的前缀回退同源,但**只做裁剪、不做结构补全**:
|
|
386
|
+
* 内容识别用它来容忍「合法 JSON + 尾部泄漏文本」(真实样本:模型把自己的工具调用
|
|
387
|
+
* 模板泄漏在 JSON 之后,且围栏没闭合)。根值正好结束在末尾时返回 null —— 那种情况
|
|
388
|
+
* `JSON.parse` 本来就会成功。
|
|
389
|
+
*
|
|
390
|
+
* @param text - 候选正文。
|
|
391
|
+
* @returns 平衡根前缀;无可裁剪内容时 null。
|
|
392
|
+
*/
|
|
393
|
+
export function trimToBalancedRoot(text: string): string | null {
|
|
394
|
+
const stack: Array<'}' | ']'> = []
|
|
395
|
+
let inString = false
|
|
396
|
+
let escaped = false
|
|
397
|
+
let started = false
|
|
398
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
399
|
+
const ch = text[i]
|
|
400
|
+
if (escaped) {
|
|
401
|
+
escaped = false
|
|
402
|
+
continue
|
|
403
|
+
}
|
|
404
|
+
if (inString) {
|
|
405
|
+
if (ch === '\\') escaped = true
|
|
406
|
+
else if (ch === '"') inString = false
|
|
407
|
+
continue
|
|
408
|
+
}
|
|
409
|
+
if (ch === '"') {
|
|
410
|
+
inString = true
|
|
411
|
+
started = true
|
|
412
|
+
continue
|
|
413
|
+
}
|
|
414
|
+
if (ch === '{') {
|
|
415
|
+
stack.push('}')
|
|
416
|
+
started = true
|
|
417
|
+
continue
|
|
418
|
+
}
|
|
419
|
+
if (ch === '[') {
|
|
420
|
+
stack.push(']')
|
|
421
|
+
started = true
|
|
422
|
+
continue
|
|
423
|
+
}
|
|
424
|
+
if (ch === '}' || ch === ']') {
|
|
425
|
+
// 不匹配的闭括号 ⇒ 这份正文结构上已经坏了,不做"裁剪"式的猜测。
|
|
426
|
+
if (stack[stack.length - 1] !== ch) return null
|
|
427
|
+
stack.pop()
|
|
428
|
+
if (stack.length === 0 && started) {
|
|
429
|
+
if (i + 1 >= text.length) return null
|
|
430
|
+
return text.slice(0, i + 1).trim()
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return null
|
|
126
435
|
}
|
|
127
436
|
|
|
128
437
|
/**
|
|
@@ -230,8 +539,8 @@ function rewriteTetrisTableColumns(raw: string): { text: string; repairs: number
|
|
|
230
539
|
* client uses the host-provided fence source; the validate tool is by
|
|
231
540
|
* definition pre-emission), so a streaming half can never flash premature UI.
|
|
232
541
|
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
542
|
+
* One shared scan implementation folds the tier-1 fixes (quote escaping +
|
|
543
|
+
* trailing-comma drops) into structural completion, so bodies with BOTH defects
|
|
235
544
|
* (a trailing comma AND a missing closer) heal in one shot — the old
|
|
236
545
|
* two-phase chain lost tier-1's partial work when its whole-body parse
|
|
237
546
|
* failed, and re-scanning the raw text could not compose the repairs.
|
|
@@ -259,97 +568,5 @@ export function completeFenceJson(raw: string): { text: string; repairs: number
|
|
|
259
568
|
if (scanned === null) return null
|
|
260
569
|
return { text: scanned.text, repairs: scanned.repairs + tetris.repairs }
|
|
261
570
|
}
|
|
262
|
-
|
|
263
|
-
const stack: Array<'}' | ']'> = []
|
|
264
|
-
let inString = false
|
|
265
|
-
let escaped = false
|
|
266
|
-
let repairs = 0
|
|
267
|
-
for (let i = 0; i < raw.length; i++) {
|
|
268
|
-
const ch = raw[i]
|
|
269
|
-
if (escaped) {
|
|
270
|
-
out += ch
|
|
271
|
-
escaped = false
|
|
272
|
-
continue
|
|
273
|
-
}
|
|
274
|
-
if (inString) {
|
|
275
|
-
if (ch === '\\') {
|
|
276
|
-
out += ch
|
|
277
|
-
escaped = true
|
|
278
|
-
continue
|
|
279
|
-
}
|
|
280
|
-
if (ch !== '"') {
|
|
281
|
-
out += ch
|
|
282
|
-
continue
|
|
283
|
-
}
|
|
284
|
-
// Inside a string: is this quote the terminator? Look past whitespace.
|
|
285
|
-
let j = i + 1
|
|
286
|
-
while (j < raw.length && (raw[j] === ' ' || raw[j] === '\t' || raw[j] === '\n' || raw[j] === '\r')) j++
|
|
287
|
-
const next = j < raw.length ? raw[j] : ''
|
|
288
|
-
if (next === ',' || next === ']' || next === '}' || next === ':' || next === '') {
|
|
289
|
-
inString = false
|
|
290
|
-
out += ch
|
|
291
|
-
} else {
|
|
292
|
-
// Free-standing quote inside a value → escape it (tier-1 fix).
|
|
293
|
-
out += '\\"'
|
|
294
|
-
repairs++
|
|
295
|
-
}
|
|
296
|
-
continue
|
|
297
|
-
}
|
|
298
|
-
if (ch === '"') {
|
|
299
|
-
inString = true
|
|
300
|
-
out += ch
|
|
301
|
-
continue
|
|
302
|
-
}
|
|
303
|
-
if (ch === '{') {
|
|
304
|
-
stack.push('}')
|
|
305
|
-
out += ch
|
|
306
|
-
continue
|
|
307
|
-
}
|
|
308
|
-
if (ch === '[') {
|
|
309
|
-
stack.push(']')
|
|
310
|
-
out += ch
|
|
311
|
-
continue
|
|
312
|
-
}
|
|
313
|
-
if (ch === '}' || ch === ']') {
|
|
314
|
-
if (stack[stack.length - 1] === ch) {
|
|
315
|
-
stack.pop()
|
|
316
|
-
out += ch
|
|
317
|
-
} else {
|
|
318
|
-
// Mismatched closer (e.g. a `]` mistyped as `}`, or a duplicated
|
|
319
|
-
// terminator): no legal JSON can contain it here, so skip it and let
|
|
320
|
-
// the remaining closers pair up again. The whole-body parse below is
|
|
321
|
-
// the final arbiter — if skipping made things worse, nothing is
|
|
322
|
-
// adopted and the diagnostic banner stays.
|
|
323
|
-
repairs++
|
|
324
|
-
}
|
|
325
|
-
continue
|
|
326
|
-
}
|
|
327
|
-
if (ch === ',') {
|
|
328
|
-
// Trailing comma before `}` / `]` / end of input → drop it (tier-1 fix).
|
|
329
|
-
let j = i + 1
|
|
330
|
-
while (j < raw.length && (raw[j] === ' ' || raw[j] === '\t' || raw[j] === '\n' || raw[j] === '\r')) j++
|
|
331
|
-
const next = j < raw.length ? raw[j] : ''
|
|
332
|
-
if (next === '}' || next === ']' || next === '') {
|
|
333
|
-
repairs++
|
|
334
|
-
continue
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
out += ch
|
|
338
|
-
}
|
|
339
|
-
if (inString) {
|
|
340
|
-
// Unterminated string value → close it.
|
|
341
|
-
out += '"'
|
|
342
|
-
repairs++
|
|
343
|
-
}
|
|
344
|
-
while (stack.length > 0) {
|
|
345
|
-
out += stack.pop()
|
|
346
|
-
repairs++
|
|
347
|
-
}
|
|
348
|
-
if (repairs === 0) return null
|
|
349
|
-
try {
|
|
350
|
-
JSON.parse(out)
|
|
351
|
-
return { text: out, repairs }
|
|
352
|
-
} catch {
|
|
353
|
-
return null
|
|
354
|
-
}
|
|
571
|
+
return scanFenceJson(raw, true)
|
|
355
572
|
}
|
package/tsdown.config.ts
CHANGED
|
@@ -245,19 +245,15 @@ function assetConfig(name: 'mermaid' | 'three' | 'echarts', entry: string): User
|
|
|
245
245
|
|
|
246
246
|
const libConfig: UserConfig = {
|
|
247
247
|
name: ID,
|
|
248
|
-
//
|
|
249
|
-
|
|
250
|
-
// source entry that plugin_check's tool-bundle contract expects.
|
|
251
|
-
entry: { index: 'src/index.ts', invariant: 'src/plugin/invariant.ts' },
|
|
248
|
+
// Keep the root plugin bundle at lib/index.js, built from src/index.ts.
|
|
249
|
+
entry: { index: 'src/index.ts' },
|
|
252
250
|
outDir: 'lib',
|
|
253
251
|
format: ['esm'],
|
|
254
252
|
platform: 'node',
|
|
255
253
|
target: 'es2024',
|
|
256
254
|
fixedExtension: false,
|
|
257
255
|
dts: false,
|
|
258
|
-
//
|
|
259
|
-
// stable paths lib/index.js + lib/invariant.js (a directory-mirroring
|
|
260
|
-
// default would emit lib/plugin/invariant.js).
|
|
256
|
+
// Keep the output name aligned with the package's root export.
|
|
261
257
|
outputOptions: { entryFileNames: '[name].js' },
|
|
262
258
|
// Cleanup happens in the build script (`rm -rf lib` before tsc): tsdown
|
|
263
259
|
// must never wipe lib/types (tsc's declaration output) mid-pipeline.
|