@zhushanwen/pi-ask-user 7.1.0 → 7.1.2
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/package.json +3 -3
- package/src/__tests__/component.test.ts +32 -0
- package/src/component.ts +54 -25
- package/src/question-view.ts +94 -53
- package/src/validate.ts +63 -35
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-ask-user",
|
|
3
|
-
"version": "7.1.
|
|
3
|
+
"version": "7.1.2",
|
|
4
4
|
"description": "Inline adaptive ask_user tool for Pi — single/multi-question structured input with split-pane preview and an inline free-text editor.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
|
@@ -29,8 +29,8 @@
|
|
|
29
29
|
"ARCHITECTURE.md"
|
|
30
30
|
],
|
|
31
31
|
"dependencies": {
|
|
32
|
-
"@xyz-agent/extension-protocol": "0.8.
|
|
33
|
-
"@zhushanwen/pi-extension-logger": "0.4.
|
|
32
|
+
"@xyz-agent/extension-protocol": "0.8.2",
|
|
33
|
+
"@zhushanwen/pi-extension-logger": "0.4.1"
|
|
34
34
|
},
|
|
35
35
|
"devDependencies": {
|
|
36
36
|
"@earendil-works/pi-tui": "^0.84.4",
|
|
@@ -820,3 +820,35 @@ describe("AskUserComponent — new behavior (post-refactor)", () => {
|
|
|
820
820
|
expect(lines.some((l: string) => l.includes("B"))).toBe(true);
|
|
821
821
|
});
|
|
822
822
|
});
|
|
823
|
+
|
|
824
|
+
// ── options 输入路由 no-op 落空语义(←/→ 与 Space 的守卫分支)──
|
|
825
|
+
describe("AskUserComponent — options input no-op edges", () => {
|
|
826
|
+
it("C-NAV-1: single question ignores LEFT/RIGHT in options mode (no tab nav, no cancel overlay)", () => {
|
|
827
|
+
const { c, result } = make([singleQ]);
|
|
828
|
+
// 单问题无 tab:←/→ 应为 no-op(isSingle 短路),既不切 tab 也不进入确认取消覆盖层
|
|
829
|
+
c.handleInput(LEFT);
|
|
830
|
+
c.handleInput(RIGHT);
|
|
831
|
+
expect(result.val).toBeUndefined();
|
|
832
|
+
// 未进入 Esc 确认取消覆盖层(覆盖层特征文案不出现)
|
|
833
|
+
expect(c.render(60).some((l) => l.includes("Cancel all"))).toBe(false);
|
|
834
|
+
// 光标仍在第一项,表单完好
|
|
835
|
+
const lines = c.render(60);
|
|
836
|
+
expect(lines.some((l) => l.includes(">") && l.includes("Postgres"))).toBe(true);
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
it("C-NAV-2: multi-select Space on Other row is a no-op (does not toggle, does not enter editor)", () => {
|
|
840
|
+
const { c, result } = make([singleQMulti]);
|
|
841
|
+
// 下移到 Other 行(Auth, Search, Other → 光标 index 2)
|
|
842
|
+
c.handleInput(DOWN);
|
|
843
|
+
c.handleInput(DOWN);
|
|
844
|
+
// Other 行上 Space:multiSelect && !onOther 为 false → 落空
|
|
845
|
+
c.handleInput(" ");
|
|
846
|
+
// 未确认、未提交
|
|
847
|
+
expect(result.val).toBeUndefined();
|
|
848
|
+
const lines = c.render(60);
|
|
849
|
+
// Other 行未被 toggle(无任何 [✓] 勾选框出现)
|
|
850
|
+
expect(lines.some((l) => l.includes("[✓]"))).toBe(false);
|
|
851
|
+
// 仍在 options 模式(未进 freeform 编辑器——反色光标不出现)
|
|
852
|
+
expect(lines.some((l) => l.includes("\x1b[7m"))).toBe(false);
|
|
853
|
+
});
|
|
854
|
+
});
|
package/src/component.ts
CHANGED
|
@@ -214,6 +214,7 @@ export class AskUserComponent implements Component {
|
|
|
214
214
|
/**
|
|
215
215
|
* options 模式的输入处理(从 handleInput 拆出)。
|
|
216
216
|
* 含:Esc 回退/确认取消、←/→ 切 tab、↑/↓ 移光标、Enter 确认、Space toggle。
|
|
217
|
+
* 主函数只留编排:Esc → tab 导航 → 光标导航 → 确认键,各阶段命中即终止。
|
|
217
218
|
*/
|
|
218
219
|
private handleOptionsInput(data: string, state: QuestionState, q: Question): void {
|
|
219
220
|
// Esc → 回退到上一个问题;在首个问题时进入确认取消覆盖层
|
|
@@ -221,59 +222,87 @@ export class AskUserComponent implements Component {
|
|
|
221
222
|
this.escBackOrConfirm();
|
|
222
223
|
return;
|
|
223
224
|
}
|
|
225
|
+
if (this.handleTabNavKeys(data)) return;
|
|
226
|
+
if (this.handleCursorNavKeys(data, state, q)) return;
|
|
227
|
+
this.handleOptionConfirmKeys(data, state, q);
|
|
228
|
+
}
|
|
224
229
|
|
|
225
|
-
|
|
230
|
+
/** ← / → 切换问题 tab(多问题,options 模式)。返回 true 表示已消费该按键。 */
|
|
231
|
+
private handleTabNavKeys(data: string): boolean {
|
|
226
232
|
if (!this.isSingle && matchesKey(data, "right")) {
|
|
227
233
|
this.gotoTab(Math.min(this.activeTab + 1, this.questions.length));
|
|
228
|
-
return;
|
|
234
|
+
return true;
|
|
229
235
|
}
|
|
230
236
|
if (!this.isSingle && matchesKey(data, "left")) {
|
|
231
237
|
this.gotoTab(Math.max(this.activeTab - 1, 0));
|
|
232
|
-
return;
|
|
238
|
+
return true;
|
|
233
239
|
}
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
234
242
|
|
|
243
|
+
/** ↑/↓ 移动选项光标(↓ 受 Other 行下界约束)。返回 true 表示已消费该按键。 */
|
|
244
|
+
private handleCursorNavKeys(data: string, state: QuestionState, q: Question): boolean {
|
|
235
245
|
if (matchesKey(data, "up")) {
|
|
236
246
|
state.cursorIndex = Math.max(0, state.cursorIndex - 1);
|
|
237
247
|
this.rerender();
|
|
238
|
-
return;
|
|
248
|
+
return true;
|
|
239
249
|
}
|
|
240
250
|
if (matchesKey(data, "down")) {
|
|
241
251
|
const max = allOptions(q).length - 1;
|
|
242
252
|
state.cursorIndex = Math.min(max, state.cursorIndex + 1);
|
|
243
253
|
this.rerender();
|
|
244
|
-
return;
|
|
254
|
+
return true;
|
|
245
255
|
}
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
246
258
|
|
|
259
|
+
/** Other 行 Enter 进编辑器 / 多选 Space toggle + Enter 确认 / 单选 Enter 确认。 */
|
|
260
|
+
private handleOptionConfirmKeys(data: string, state: QuestionState, q: Question): void {
|
|
247
261
|
const opts = allOptions(q);
|
|
248
262
|
const onOther = state.cursorIndex === opts.length - 1;
|
|
249
263
|
|
|
250
264
|
// Other row → Enter opens freeform editor
|
|
251
265
|
if (onOther && matchesKey(data, "enter")) {
|
|
252
|
-
|
|
253
|
-
state.mode = "freeform";
|
|
254
|
-
state.draftText = state.freeTextValue ?? state.freeDraft ?? "";
|
|
255
|
-
state.cursorIndex = state.draftText.length;
|
|
256
|
-
this.rerender();
|
|
266
|
+
this.openOtherEditor(state);
|
|
257
267
|
return;
|
|
258
268
|
}
|
|
259
269
|
|
|
260
270
|
if (q.multiSelect && !onOther) {
|
|
261
|
-
|
|
262
|
-
this.toggleIndex(state, state.cursorIndex);
|
|
263
|
-
return;
|
|
264
|
-
}
|
|
265
|
-
if (matchesKey(data, "enter")) {
|
|
266
|
-
state.selectedIndices.add(state.cursorIndex);
|
|
267
|
-
this.afterConfirm(state);
|
|
268
|
-
return;
|
|
269
|
-
}
|
|
271
|
+
this.handleMultiSelectConfirmKeys(data, state);
|
|
270
272
|
} else if (!q.multiSelect && !onOther) {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
273
|
+
this.handleSingleSelectConfirmKeys(data, state);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Other 行 Enter:进 freeform 编辑器,预填已提交文本或上一次草稿。 */
|
|
278
|
+
private openOtherEditor(state: QuestionState): void {
|
|
279
|
+
state.savedOptionsCursorIndex = state.cursorIndex;
|
|
280
|
+
state.mode = "freeform";
|
|
281
|
+
state.draftText = state.freeTextValue ?? state.freeDraft ?? "";
|
|
282
|
+
state.cursorIndex = state.draftText.length;
|
|
283
|
+
this.rerender();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** 多选(非 Other 行):Space toggle;Enter 选中光标项并确认前进。 */
|
|
287
|
+
private handleMultiSelectConfirmKeys(data: string, state: QuestionState): void {
|
|
288
|
+
if (matchesKey(data, "space")) {
|
|
289
|
+
this.toggleIndex(state, state.cursorIndex);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (matchesKey(data, "enter")) {
|
|
293
|
+
state.selectedIndices.add(state.cursorIndex);
|
|
294
|
+
this.afterConfirm(state);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** 单选(非 Other 行):Enter 选中光标项(清 Other 文本)并确认前进。 */
|
|
300
|
+
private handleSingleSelectConfirmKeys(data: string, state: QuestionState): void {
|
|
301
|
+
if (matchesKey(data, "enter")) {
|
|
302
|
+
state.selectedIndex = state.cursorIndex;
|
|
303
|
+
state.freeTextValue = null;
|
|
304
|
+
this.afterConfirm(state);
|
|
305
|
+
return;
|
|
277
306
|
}
|
|
278
307
|
}
|
|
279
308
|
|
package/src/question-view.ts
CHANGED
|
@@ -124,6 +124,7 @@ const EDITOR_HINT = " ←/→ Home/End move · Backspace deletes · Enter submit
|
|
|
124
124
|
/**
|
|
125
125
|
* 构建选项列表行(不含分屏预览)。hideDescriptions 用于分屏模式左列。
|
|
126
126
|
* freeform 模式下,Other 行**原地**变 [ ] <input> 反色光标(多选)/ <input> 反色光标(单选)。
|
|
127
|
+
* 主函数只留逐行编排:Other / 多选 / 单选三类行各自提取为独立 append helper。
|
|
127
128
|
*/
|
|
128
129
|
function buildOptionLines(
|
|
129
130
|
ctx: RenderContext,
|
|
@@ -141,68 +142,108 @@ function buildOptionLines(
|
|
|
141
142
|
// 编辑器模式下用 savedOptionsCursorIndex 判断选项高亮,cursorIndex 此时是文本光标
|
|
142
143
|
const activeOptionCursor = state.mode === "freeform" ? state.savedOptionsCursorIndex : state.cursorIndex;
|
|
143
144
|
const isSelected = i === activeOptionCursor;
|
|
144
|
-
const isOther = opt.isOther === true;
|
|
145
145
|
const prefix = isSelected ? t.fg("accent", ">") : " ";
|
|
146
146
|
|
|
147
|
-
if (isOther) {
|
|
148
|
-
|
|
149
|
-
// 单选 check = 1 列,多选 box = 3 列。
|
|
150
|
-
// 此前单选 freeform 占位用 " "(2列)、多选非 freeform 用 check(1列),
|
|
151
|
-
// 两种情况下 Other 编号都与普通选项错位。
|
|
152
|
-
if (state.mode === "freeform") {
|
|
153
|
-
const marker = q.multiSelect ? t.fg("dim", "[ ]") : " ";
|
|
154
|
-
const num = i + 1;
|
|
155
|
-
const lead = `${prefix} ${marker} `;
|
|
156
|
-
const avail = Math.max(1, width - visibleWidth(lead));
|
|
157
|
-
// 编号 + 文本,光标用反色高亮当前字符(surrogate pair 安全,不占额外位置)
|
|
158
|
-
const cursorText = renderCursorText(state.draftText, state.cursorIndex);
|
|
159
|
-
const styled = `${t.fg("muted", `${num}. `)}${t.fg("text", cursorText)}`;
|
|
160
|
-
addWrappedInput(add, lead, styled, avail, MAX_EDITOR_LINES);
|
|
161
|
-
} else {
|
|
162
|
-
const hasFreeText = state.freeTextValue !== null;
|
|
163
|
-
const marker = q.multiSelect
|
|
164
|
-
? (hasFreeText ? t.fg("success", "[✓]") : t.fg("dim", "[ ]"))
|
|
165
|
-
: (hasFreeText ? t.fg("success", "✓") : " ");
|
|
166
|
-
const labelColor = isSelected ? "accent" : "text";
|
|
167
|
-
const num = i + 1;
|
|
168
|
-
add(`${prefix} ${marker} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
|
|
169
|
-
if (hasFreeText) {
|
|
170
|
-
// 预览缩进对齐到 label 起始列:prefix + sp + marker + sp + "N." + sp。
|
|
171
|
-
// 随 num 位数与单/多选 marker 宽度动态变化,硬编码会错位。
|
|
172
|
-
const numStr = `${num}.`;
|
|
173
|
-
const lead = " ".repeat(
|
|
174
|
-
visibleWidth(prefix) + 1 + visibleWidth(marker) + 1 + numStr.length + 1,
|
|
175
|
-
);
|
|
176
|
-
const avail = Math.max(1, width - visibleWidth(lead));
|
|
177
|
-
const styled = t.fg("dim", `"${state.freeTextValue ?? ""}"`);
|
|
178
|
-
addWrappedInput(add, lead, styled, avail, MAX_EDITOR_LINES);
|
|
179
|
-
}
|
|
180
|
-
}
|
|
147
|
+
if (opt.isOther === true) {
|
|
148
|
+
appendOtherRow(ctx, opt, i, isSelected, prefix, add);
|
|
181
149
|
} else if (q.multiSelect) {
|
|
182
|
-
|
|
183
|
-
const box = checked ? t.fg("accent", "[✓]") : t.fg("dim", "[ ]");
|
|
184
|
-
const labelColor = isSelected ? "accent" : "text";
|
|
185
|
-
const num = i + 1;
|
|
186
|
-
add(`${prefix} ${box} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
|
|
187
|
-
if (opt.description && !hideDescriptions) {
|
|
188
|
-
const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width - DESCRIPTION_INDENT_MULTI);
|
|
189
|
-
for (const line of wrapped) add(` ${line}`);
|
|
190
|
-
}
|
|
150
|
+
appendMultiSelectRow(ctx, opt, i, isSelected, prefix, hideDescriptions, add);
|
|
191
151
|
} else {
|
|
192
|
-
|
|
193
|
-
const check = isConfirmed ? t.fg("success", "✓") : " ";
|
|
194
|
-
const labelColor = isSelected ? "accent" : "text";
|
|
195
|
-
const num = i + 1;
|
|
196
|
-
add(`${prefix} ${check} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
|
|
197
|
-
if (opt.description && !hideDescriptions) {
|
|
198
|
-
const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width - DESCRIPTION_INDENT_SINGLE);
|
|
199
|
-
for (const line of wrapped) add(` ${line}`);
|
|
200
|
-
}
|
|
152
|
+
appendSingleSelectRow(ctx, opt, i, isSelected, prefix, hideDescriptions, add);
|
|
201
153
|
}
|
|
202
154
|
}
|
|
203
155
|
return lines;
|
|
204
156
|
}
|
|
205
157
|
|
|
158
|
+
/** Other 行:freeform 原地编辑器(反色光标)/ 非 freeform 的 ✓ 标记 + 已保存文本预览。 */
|
|
159
|
+
function appendOtherRow(
|
|
160
|
+
ctx: RenderContext,
|
|
161
|
+
opt: DisplayOption,
|
|
162
|
+
index: number,
|
|
163
|
+
isSelected: boolean,
|
|
164
|
+
prefix: string,
|
|
165
|
+
add: (s: string) => void,
|
|
166
|
+
): void {
|
|
167
|
+
const { question: q, state, theme: t, width } = ctx;
|
|
168
|
+
const num = index + 1;
|
|
169
|
+
|
|
170
|
+
if (state.mode === "freeform") {
|
|
171
|
+
// 标记位宽度必须与普通选项一致,否则编号列错位:
|
|
172
|
+
// 单选 check = 1 列,多选 box = 3 列。
|
|
173
|
+
// 此前单选 freeform 占位用 " "(2列)、多选非 freeform 用 check(1列),
|
|
174
|
+
// 两种情况下 Other 编号都与普通选项错位。
|
|
175
|
+
const marker = q.multiSelect ? t.fg("dim", "[ ]") : " ";
|
|
176
|
+
const lead = `${prefix} ${marker} `;
|
|
177
|
+
const avail = Math.max(1, width - visibleWidth(lead));
|
|
178
|
+
// 编号 + 文本,光标用反色高亮当前字符(surrogate pair 安全,不占额外位置)
|
|
179
|
+
const cursorText = renderCursorText(state.draftText, state.cursorIndex);
|
|
180
|
+
const styled = `${t.fg("muted", `${num}. `)}${t.fg("text", cursorText)}`;
|
|
181
|
+
addWrappedInput(add, lead, styled, avail, MAX_EDITOR_LINES);
|
|
182
|
+
} else {
|
|
183
|
+
const hasFreeText = state.freeTextValue !== null;
|
|
184
|
+
const marker = q.multiSelect
|
|
185
|
+
? (hasFreeText ? t.fg("success", "[✓]") : t.fg("dim", "[ ]"))
|
|
186
|
+
: (hasFreeText ? t.fg("success", "✓") : " ");
|
|
187
|
+
const labelColor = isSelected ? "accent" : "text";
|
|
188
|
+
add(`${prefix} ${marker} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
|
|
189
|
+
if (hasFreeText) {
|
|
190
|
+
// 预览缩进对齐到 label 起始列:prefix + sp + marker + sp + "N." + sp。
|
|
191
|
+
// 随 num 位数与单/多选 marker 宽度动态变化,硬编码会错位。
|
|
192
|
+
const numStr = `${num}.`;
|
|
193
|
+
const lead = " ".repeat(
|
|
194
|
+
visibleWidth(prefix) + 1 + visibleWidth(marker) + 1 + numStr.length + 1,
|
|
195
|
+
);
|
|
196
|
+
const avail = Math.max(1, width - visibleWidth(lead));
|
|
197
|
+
const styled = t.fg("dim", `"${state.freeTextValue ?? ""}"`);
|
|
198
|
+
addWrappedInput(add, lead, styled, avail, MAX_EDITOR_LINES);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** 多选普通选项行:勾选框 + 编号 label + 缩进描述(hideDescriptions 时省略描述)。 */
|
|
204
|
+
function appendMultiSelectRow(
|
|
205
|
+
ctx: RenderContext,
|
|
206
|
+
opt: DisplayOption,
|
|
207
|
+
index: number,
|
|
208
|
+
isSelected: boolean,
|
|
209
|
+
prefix: string,
|
|
210
|
+
hideDescriptions: boolean,
|
|
211
|
+
add: (s: string) => void,
|
|
212
|
+
): void {
|
|
213
|
+
const { state, theme: t, width } = ctx;
|
|
214
|
+
const checked = state.selectedIndices.has(index);
|
|
215
|
+
const box = checked ? t.fg("accent", "[✓]") : t.fg("dim", "[ ]");
|
|
216
|
+
const labelColor = isSelected ? "accent" : "text";
|
|
217
|
+
const num = index + 1;
|
|
218
|
+
add(`${prefix} ${box} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
|
|
219
|
+
if (opt.description && !hideDescriptions) {
|
|
220
|
+
const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width - DESCRIPTION_INDENT_MULTI);
|
|
221
|
+
for (const line of wrapped) add(` ${line}`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** 单选普通选项行:✓ 确认标记 + 编号 label + 缩进描述(hideDescriptions 时省略描述)。 */
|
|
226
|
+
function appendSingleSelectRow(
|
|
227
|
+
ctx: RenderContext,
|
|
228
|
+
opt: DisplayOption,
|
|
229
|
+
index: number,
|
|
230
|
+
isSelected: boolean,
|
|
231
|
+
prefix: string,
|
|
232
|
+
hideDescriptions: boolean,
|
|
233
|
+
add: (s: string) => void,
|
|
234
|
+
): void {
|
|
235
|
+
const { state, theme: t, width } = ctx;
|
|
236
|
+
const isConfirmed = state.selectedIndex === index;
|
|
237
|
+
const check = isConfirmed ? t.fg("success", "✓") : " ";
|
|
238
|
+
const labelColor = isSelected ? "accent" : "text";
|
|
239
|
+
const num = index + 1;
|
|
240
|
+
add(`${prefix} ${check} ${t.fg(labelColor, `${num}. ${opt.label}`)}`);
|
|
241
|
+
if (opt.description && !hideDescriptions) {
|
|
242
|
+
const wrapped = wrapTextWithAnsi(t.fg("muted", opt.description), width - DESCRIPTION_INDENT_SINGLE);
|
|
243
|
+
for (const line of wrapped) add(` ${line}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
206
247
|
/** 构建分屏右侧 Markdown 详情预览。 */
|
|
207
248
|
function buildPreviewLines(
|
|
208
249
|
ctx: RenderContext,
|
package/src/validate.ts
CHANGED
|
@@ -22,48 +22,74 @@ const ERROR_PREVIEW_CHARS = 20;
|
|
|
22
22
|
* - 多问题(questions.length > 1)时每个 question 必须有非空 header
|
|
23
23
|
*
|
|
24
24
|
* 错误消息面向 LLM:除描述违规外,附带一句修复指引(如何改),对结构误用附 Correct 正例。
|
|
25
|
+
*
|
|
26
|
+
* 主函数只留编排:逐 question 依序跑「文本 → 唯一性 → option labels」,再跑
|
|
27
|
+
* 多问题 header 校验与 header 长度校验。各阶段提取为独立 helper(?? 链按序短路求值,
|
|
28
|
+
* 与原 early-return 顺序一致)。校验顺序即错误优先级,不可调换。
|
|
25
29
|
*/
|
|
26
30
|
export function validateInput(questions: InputQuestion[]): string | null {
|
|
27
31
|
const seenQuestions = new Set<string>();
|
|
28
32
|
|
|
29
33
|
for (const q of questions) {
|
|
30
34
|
const qt = q.question;
|
|
35
|
+
const perQuestionError =
|
|
36
|
+
checkQuestionText(qt) ??
|
|
37
|
+
checkDuplicateQuestion(qt, seenQuestions) ??
|
|
38
|
+
checkOptionLabels(qt, q.options);
|
|
39
|
+
if (perQuestionError) return perQuestionError;
|
|
40
|
+
}
|
|
31
41
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
return `Question text exceeds ${QUESTION_MAX_CHARS} chars: "${qt.slice(0, ERROR_PREVIEW_CHARS)}...". Shorten it to a single concise decision; move extra context into the context field.`;
|
|
35
|
-
}
|
|
36
|
-
// 1b. question 文本无控制字符(key 可预测,不影响下游渲染/解析)
|
|
37
|
-
if (CONTROL_CHAR_RE.test(qt)) {
|
|
38
|
-
return `Question text must not contain control characters (incl. newlines): "${qt.slice(0, ERROR_PREVIEW_CHARS)}...". Use plain single-line text; split multi-part questions into separate entries.`;
|
|
39
|
-
}
|
|
42
|
+
return checkMultiQuestionHeaders(questions) ?? checkHeaderLengths(questions);
|
|
43
|
+
}
|
|
40
44
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
|
|
45
|
+
/** 1a. question 文本长度上限(key 有界);1b. 无控制字符(key 可预测,不影响下游渲染/解析)。 */
|
|
46
|
+
function checkQuestionText(qt: string): string | null {
|
|
47
|
+
if (qt.length > QUESTION_MAX_CHARS) {
|
|
48
|
+
return `Question text exceeds ${QUESTION_MAX_CHARS} chars: "${qt.slice(0, ERROR_PREVIEW_CHARS)}...". Shorten it to a single concise decision; move extra context into the context field.`;
|
|
49
|
+
}
|
|
50
|
+
if (CONTROL_CHAR_RE.test(qt)) {
|
|
51
|
+
return `Question text must not contain control characters (incl. newlines): "${qt.slice(0, ERROR_PREVIEW_CHARS)}...". Use plain single-line text; split multi-part questions into separate entries.`;
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
46
55
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
56
|
+
/** 1c. question 文本唯一。未重复时登记到 seenQuestions(调用方依赖此副作用)。 */
|
|
57
|
+
function checkDuplicateQuestion(qt: string, seenQuestions: Set<string>): string | null {
|
|
58
|
+
if (seenQuestions.has(qt)) {
|
|
59
|
+
return `Duplicate question: "${qt}". Each question text must be unique; merge duplicates or rephrase one to differ.`;
|
|
60
|
+
}
|
|
61
|
+
seenQuestions.add(qt);
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 2. option 元素必须是 {label, description} 对象,不能是 string。
|
|
67
|
+
* 弱模型最高频误用:"options":["A","B"]。schema 层已放宽让 string 进来,这里友好拦截
|
|
68
|
+
* (InputQuestion.options 是 (Option | string)[],typeof 收窄后 opt 为 Option)。
|
|
69
|
+
*/
|
|
70
|
+
function checkOptionLabels(qt: string, options: InputQuestion["options"]): string | null {
|
|
71
|
+
const seenLabels = new Set<string>();
|
|
72
|
+
for (const opt of options) {
|
|
73
|
+
if (typeof opt === "string") {
|
|
74
|
+
return `Options for question "${qt}" must be an array of {label, description} objects, not strings. Correct: "options":[{"label":"A","description":"..."},{"label":"B","description":"..."}]`;
|
|
63
75
|
}
|
|
76
|
+
// opt 已收窄为 Option
|
|
77
|
+
if (opt.label.trim() === "") {
|
|
78
|
+
return `Option label must not be empty in question "${qt}". Give every option a distinct, descriptive label.`;
|
|
79
|
+
}
|
|
80
|
+
if (seenLabels.has(opt.label)) {
|
|
81
|
+
return `Duplicate option label "${opt.label}" in question "${qt}". Options must be mutually exclusive — reword one so each label maps to a distinct choice.`;
|
|
82
|
+
}
|
|
83
|
+
seenLabels.add(opt.label);
|
|
64
84
|
}
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
65
87
|
|
|
66
|
-
|
|
88
|
+
/**
|
|
89
|
+
* 3. 多问题时 header 必填且非空;S3: header 唯一——重复 header 会导致 askUserKey 碰撞,
|
|
90
|
+
* 后一个 question 的 __other 覆盖前一个(协议 helper 用 header 作 answers 读取 key)。
|
|
91
|
+
*/
|
|
92
|
+
function checkMultiQuestionHeaders(questions: InputQuestion[]): string | null {
|
|
67
93
|
if (questions.length > 1) {
|
|
68
94
|
for (const q of questions) {
|
|
69
95
|
if (!q.header || q.header.trim() === "") {
|
|
@@ -71,8 +97,6 @@ export function validateInput(questions: InputQuestion[]): string | null {
|
|
|
71
97
|
}
|
|
72
98
|
}
|
|
73
99
|
|
|
74
|
-
// S3: 多问题时 header 唯一——重复 header 会导致 askUserKey 碰撞,
|
|
75
|
-
// 后一个 question 的 __other 覆盖前一个(协议 helper 用 header 作 answers 读取 key)。
|
|
76
100
|
const seenHeaders = new Set<string>();
|
|
77
101
|
for (const q of questions) {
|
|
78
102
|
const h = q.header!.trim();
|
|
@@ -82,14 +106,18 @@ export function validateInput(questions: InputQuestion[]): string | null {
|
|
|
82
106
|
seenHeaders.add(h);
|
|
83
107
|
}
|
|
84
108
|
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
85
111
|
|
|
86
|
-
|
|
87
|
-
|
|
112
|
+
/**
|
|
113
|
+
* 4. header 长度上限(若提供)。单/多问题均校验:超出会在 tab 栏被静默截断,
|
|
114
|
+
* 这里提前拒绝,让 LLM 拿到可修复错误而非残缺 UI(兑现 schema description 的 ≤12 契约)。
|
|
115
|
+
*/
|
|
116
|
+
function checkHeaderLengths(questions: InputQuestion[]): string | null {
|
|
88
117
|
for (const q of questions) {
|
|
89
118
|
if (q.header !== undefined && q.header.length > HEADER_MAX_CHARS) {
|
|
90
119
|
return `Header exceeds ${HEADER_MAX_CHARS} chars: "${q.header.slice(0, ERROR_PREVIEW_CHARS)}..." in question "${q.question}". Shorten it; longer headers are truncated in the tab bar.`;
|
|
91
120
|
}
|
|
92
121
|
}
|
|
93
|
-
|
|
94
122
|
return null;
|
|
95
123
|
}
|