lite-questionnaire 1.0.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 +21 -0
- package/README.md +358 -0
- package/core.ts +477 -0
- package/design/questionnaire-openapi.yaml +522 -0
- package/index.ts +396 -0
- package/input.ts +513 -0
- package/modules/confirm.ts +42 -0
- package/modules/multiSelect.ts +100 -0
- package/modules/rating.ts +105 -0
- package/modules/select.ts +118 -0
- package/modules/shared.ts +10 -0
- package/modules/text.ts +71 -0
- package/package.json +39 -0
- package/render.ts +319 -0
- package/skills/lite-questionnaire/SKILL.md +276 -0
- package/state.ts +39 -0
- package/types.ts +137 -0
package/index.ts
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Questionnaire 工具入口
|
|
3
|
+
*
|
|
4
|
+
* 注册统一的 questionnaire 工具,支持:
|
|
5
|
+
* - 5 种问题类型:select / multiSelect / text / confirm / rating
|
|
6
|
+
* - 条件子问题(children + showIf)
|
|
7
|
+
* - 声明式约束校验
|
|
8
|
+
* - 三色进度点
|
|
9
|
+
* - 会话持久化
|
|
10
|
+
* - 自定义选项
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import { Type } from "typebox";
|
|
15
|
+
import { Editor, type EditorTheme, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
16
|
+
import { Core } from "./core";
|
|
17
|
+
import type { Answer, CancelledResult, QuestionnaireParams, QuestionnaireResult } from "./types";
|
|
18
|
+
import {
|
|
19
|
+
panelTop,
|
|
20
|
+
panelBottom,
|
|
21
|
+
panelLine,
|
|
22
|
+
renderTabBar,
|
|
23
|
+
renderPrompt,
|
|
24
|
+
renderHelpBar,
|
|
25
|
+
renderSubmitPage,
|
|
26
|
+
renderCallText,
|
|
27
|
+
renderResultText,
|
|
28
|
+
} from "./render";
|
|
29
|
+
import { createInputHandler } from "./input";
|
|
30
|
+
import { renderSelectOptions } from "./modules/select";
|
|
31
|
+
import { renderMultiSelectOptions } from "./modules/multiSelect";
|
|
32
|
+
import { renderTextQuestion } from "./modules/text";
|
|
33
|
+
import { renderConfirmQuestion } from "./modules/confirm";
|
|
34
|
+
import { renderRatingQuestion } from "./modules/rating";
|
|
35
|
+
import { loadSnapshot, saveSnapshot } from "./state";
|
|
36
|
+
|
|
37
|
+
// ─── TypeBox Schema ────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
const ConstraintSchema = Type.Object({
|
|
40
|
+
type: Type.Union([
|
|
41
|
+
Type.Literal("required"),
|
|
42
|
+
Type.Literal("minSelect"),
|
|
43
|
+
Type.Literal("maxSelect"),
|
|
44
|
+
Type.Literal("minLength"),
|
|
45
|
+
Type.Literal("maxLength"),
|
|
46
|
+
Type.Literal("pattern"),
|
|
47
|
+
]),
|
|
48
|
+
value: Type.Optional(Type.Union([Type.Number(), Type.String()])),
|
|
49
|
+
message: Type.String(),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const OptionSchema = Type.Object({
|
|
53
|
+
value: Type.String({ description: "选项的唯一值,作为答案返回" }),
|
|
54
|
+
label: Type.String({ description: "选项的显示文本" }),
|
|
55
|
+
description: Type.Optional(Type.String({ description: "选项的可选描述" })),
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const ShowIfSchema = Type.Object({
|
|
59
|
+
value: Type.String({ description: "父问题答案匹配值,匹配时显示当前子问题" }),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const BaseQuestionProps = {
|
|
63
|
+
id: Type.String({ description: "问题唯一标识" }),
|
|
64
|
+
label: Type.String({ description: "Tab 栏短标签" }),
|
|
65
|
+
prompt: Type.String({ description: "完整问题文本" }),
|
|
66
|
+
constraints: Type.Optional(Type.Array(ConstraintSchema)),
|
|
67
|
+
showIf: Type.Optional(ShowIfSchema),
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const QuestionSchema = Type.Cyclic(
|
|
71
|
+
{
|
|
72
|
+
Question: Type.Union([
|
|
73
|
+
Type.Object({
|
|
74
|
+
...BaseQuestionProps,
|
|
75
|
+
type: Type.Literal("select"),
|
|
76
|
+
maxSelect: Type.Literal(1),
|
|
77
|
+
options: Type.Array(OptionSchema, { minItems: 1 }),
|
|
78
|
+
children: Type.Optional(Type.Array(Type.Ref("Question"))),
|
|
79
|
+
}),
|
|
80
|
+
Type.Object({
|
|
81
|
+
...BaseQuestionProps,
|
|
82
|
+
type: Type.Literal("multiSelect"),
|
|
83
|
+
maxSelect: Type.Integer({ minimum: 2 }),
|
|
84
|
+
options: Type.Array(OptionSchema, { minItems: 1 }),
|
|
85
|
+
children: Type.Optional(Type.Array(Type.Ref("Question"))),
|
|
86
|
+
}),
|
|
87
|
+
Type.Object({
|
|
88
|
+
...BaseQuestionProps,
|
|
89
|
+
type: Type.Literal("text"),
|
|
90
|
+
placeholder: Type.Optional(Type.String()),
|
|
91
|
+
multiline: Type.Optional(Type.Boolean({ default: false })),
|
|
92
|
+
children: Type.Optional(Type.Array(Type.Ref("Question"))),
|
|
93
|
+
}),
|
|
94
|
+
Type.Object({
|
|
95
|
+
...BaseQuestionProps,
|
|
96
|
+
type: Type.Literal("confirm"),
|
|
97
|
+
yesLabel: Type.Optional(Type.String()),
|
|
98
|
+
noLabel: Type.Optional(Type.String()),
|
|
99
|
+
children: Type.Optional(Type.Array(Type.Ref("Question"))),
|
|
100
|
+
}),
|
|
101
|
+
Type.Object({
|
|
102
|
+
...BaseQuestionProps,
|
|
103
|
+
type: Type.Literal("rating"),
|
|
104
|
+
range: Type.Object({ min: Type.Literal(1), max: Type.Literal(5) }),
|
|
105
|
+
showEmoji: Type.Optional(Type.Boolean({ default: false })),
|
|
106
|
+
annotations: Type.Optional(Type.Record(Type.String(), Type.String())),
|
|
107
|
+
children: Type.Optional(Type.Array(Type.Ref("Question"))),
|
|
108
|
+
}),
|
|
109
|
+
]),
|
|
110
|
+
},
|
|
111
|
+
"Question",
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
const QuestionnaireParamsSchema = Type.Object({
|
|
115
|
+
questions: Type.Array(QuestionSchema, { minItems: 1 }),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
function findQuestion(questions: QuestionnaireParams["questions"], id: string): QuestionnaireParams["questions"][number] | undefined {
|
|
119
|
+
for (const q of questions) {
|
|
120
|
+
if (q.id === id) return q;
|
|
121
|
+
const child = q.children ? findQuestion(q.children, id) : undefined;
|
|
122
|
+
if (child) return child;
|
|
123
|
+
}
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function questionnaireKey(questions: QuestionnaireParams["questions"]): string {
|
|
128
|
+
const parts: string[] = [];
|
|
129
|
+
const walk = (qs: QuestionnaireParams["questions"], prefix: string) => {
|
|
130
|
+
for (const q of qs) {
|
|
131
|
+
parts.push(`${prefix}${q.id}:${q.type}`);
|
|
132
|
+
if (q.children) walk(q.children, `${prefix}${q.id}/`);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
walk(questions, "");
|
|
136
|
+
return parts.join("|");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ─── 入口 ──────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
export default function questionnaire(pi: ExtensionAPI) {
|
|
142
|
+
pi.registerTool({
|
|
143
|
+
name: "questionnaire",
|
|
144
|
+
label: "Questionnaire",
|
|
145
|
+
description:
|
|
146
|
+
"向用户展示交互式问卷。支持单选、多选、文本输入、确认、评分五种问题类型。" +
|
|
147
|
+
"支持条件子问题、约束校验、自定义选项和会话持久化。",
|
|
148
|
+
promptSnippet:
|
|
149
|
+
"向用户展示交互式问卷(单选/多选/文本/确认/评分),支持条件子问题和约束校验",
|
|
150
|
+
promptGuidelines: [
|
|
151
|
+
"使用 questionnaire 工具向用户收集结构化信息。支持 select(单选)、multiSelect(多选)、text(文本)、confirm(确认)、rating(评分) 五种类型。",
|
|
152
|
+
"使用 children + showIf 定义条件子问题:当父问题答案为指定值时,子问题动态插入到问卷中。",
|
|
153
|
+
"使用 constraints 数组定义校验规则:required/minSelect/maxSelect/minLength/maxLength/pattern。",
|
|
154
|
+
],
|
|
155
|
+
parameters: QuestionnaireParamsSchema,
|
|
156
|
+
|
|
157
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
158
|
+
const input = params as QuestionnaireParams;
|
|
159
|
+
|
|
160
|
+
if (!ctx.hasUI) {
|
|
161
|
+
return {
|
|
162
|
+
content: [
|
|
163
|
+
{
|
|
164
|
+
type: "text",
|
|
165
|
+
text: "Error: UI not available (running in non-interactive mode)",
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
details: { cancelled: true, message: "Error: UI not available (running in non-interactive mode)" },
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!input.questions || input.questions.length === 0) {
|
|
173
|
+
return {
|
|
174
|
+
content: [{ type: "text", text: "Error: No questions provided" }],
|
|
175
|
+
details: { cancelled: true, message: "Error: No questions provided" },
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const core = new Core();
|
|
180
|
+
core.init(input.questions);
|
|
181
|
+
const originalQuestions = input.questions;
|
|
182
|
+
const snapshotKey = questionnaireKey(originalQuestions);
|
|
183
|
+
const snapshot = loadSnapshot(
|
|
184
|
+
ctx.sessionManager.getBranch() as Array<{ type: string; customType?: string; data?: unknown }>,
|
|
185
|
+
snapshotKey,
|
|
186
|
+
);
|
|
187
|
+
if (snapshot) {
|
|
188
|
+
core.restore(snapshot);
|
|
189
|
+
core.questions = Core.expand(originalQuestions, core.answers);
|
|
190
|
+
core.pruneInactiveState();
|
|
191
|
+
if (core.currentIndex > core.questions.length) {
|
|
192
|
+
core.currentIndex = core.questions.length;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const result = await ctx.ui.custom<QuestionnaireResult | CancelledResult>(
|
|
197
|
+
(tui, theme, _kb, done) => {
|
|
198
|
+
const editorTheme: EditorTheme = {
|
|
199
|
+
borderColor: (s) => theme.fg("accent", s),
|
|
200
|
+
selectList: {
|
|
201
|
+
selectedPrefix: (t) => theme.fg("accent", t),
|
|
202
|
+
selectedText: (t) => theme.fg("accent", t),
|
|
203
|
+
description: (t) => theme.fg("muted", t),
|
|
204
|
+
scrollInfo: (t) => theme.fg("dim", t),
|
|
205
|
+
noMatch: (t) => theme.fg("warning", t),
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
const editor = new Editor(tui, editorTheme);
|
|
209
|
+
|
|
210
|
+
let cachedLines: string[] | undefined;
|
|
211
|
+
|
|
212
|
+
function refresh() {
|
|
213
|
+
cachedLines = undefined;
|
|
214
|
+
tui.requestRender();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const handleSubmit = (cancelled: boolean) => {
|
|
218
|
+
saveSnapshot(pi, core, snapshotKey);
|
|
219
|
+
if (cancelled) {
|
|
220
|
+
done({ cancelled: true as const, message: "User cancelled the questionnaire" });
|
|
221
|
+
} else {
|
|
222
|
+
done(core.toResult());
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
const handleSave = () => {
|
|
227
|
+
saveSnapshot(pi, core, snapshotKey);
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const handleInput = createInputHandler(
|
|
231
|
+
core,
|
|
232
|
+
editor,
|
|
233
|
+
handleSubmit,
|
|
234
|
+
refresh,
|
|
235
|
+
theme,
|
|
236
|
+
originalQuestions,
|
|
237
|
+
handleSave,
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
function render(width: number): string[] {
|
|
241
|
+
if (cachedLines) return cachedLines;
|
|
242
|
+
|
|
243
|
+
const lines: string[] = [];
|
|
244
|
+
const q = core.currentQuestion();
|
|
245
|
+
const isMultiQuestion = core.questions.length > 1;
|
|
246
|
+
|
|
247
|
+
lines.push(panelTop(width, theme));
|
|
248
|
+
|
|
249
|
+
if (isMultiQuestion) {
|
|
250
|
+
lines.push(...renderTabBar(core, width, theme));
|
|
251
|
+
lines.push(panelLine("", width));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (core.isSubmitTab()) {
|
|
255
|
+
lines.push(
|
|
256
|
+
panelLine(theme.fg("accent", theme.bold(" 确认提交")), width),
|
|
257
|
+
);
|
|
258
|
+
lines.push(panelLine("", width));
|
|
259
|
+
const submitLines = renderSubmitPage(core, theme);
|
|
260
|
+
for (const line of submitLines) {
|
|
261
|
+
lines.push(panelLine(line, width));
|
|
262
|
+
}
|
|
263
|
+
} else if (core.inputMode && q && (q.type === "select" || q.type === "multiSelect")) {
|
|
264
|
+
lines.push(panelLine(renderPrompt(q, theme), width));
|
|
265
|
+
lines.push(panelLine("", width));
|
|
266
|
+
|
|
267
|
+
if (q.type === "select") {
|
|
268
|
+
for (const line of renderSelectOptions(core, width, theme, true)) {
|
|
269
|
+
lines.push(panelLine(line, width));
|
|
270
|
+
}
|
|
271
|
+
} else if (q.type === "multiSelect") {
|
|
272
|
+
for (const line of renderMultiSelectOptions(core, width, theme)) {
|
|
273
|
+
lines.push(panelLine(line, width));
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
lines.push(panelLine("", width));
|
|
278
|
+
lines.push(panelLine(theme.fg("muted", " 自定义内容:"), width));
|
|
279
|
+
|
|
280
|
+
for (const line of editor.render(width - 4)) {
|
|
281
|
+
lines.push(panelLine(" " + line, width));
|
|
282
|
+
}
|
|
283
|
+
lines.push(panelLine("", width));
|
|
284
|
+
} else if (q) {
|
|
285
|
+
lines.push(panelLine(renderPrompt(q, theme), width));
|
|
286
|
+
lines.push(panelLine("", width));
|
|
287
|
+
|
|
288
|
+
switch (q.type) {
|
|
289
|
+
case "select":
|
|
290
|
+
for (const line of renderSelectOptions(core, width, theme, false)) {
|
|
291
|
+
lines.push(panelLine(line, width));
|
|
292
|
+
}
|
|
293
|
+
break;
|
|
294
|
+
case "multiSelect":
|
|
295
|
+
for (const line of renderMultiSelectOptions(core, width, theme)) {
|
|
296
|
+
lines.push(panelLine(line, width));
|
|
297
|
+
}
|
|
298
|
+
break;
|
|
299
|
+
case "text":
|
|
300
|
+
for (const line of renderTextQuestion(core, width, theme, editor)) {
|
|
301
|
+
lines.push(panelLine(line, width));
|
|
302
|
+
}
|
|
303
|
+
break;
|
|
304
|
+
case "confirm":
|
|
305
|
+
for (const line of renderConfirmQuestion(core, width, theme)) {
|
|
306
|
+
lines.push(panelLine(line, width));
|
|
307
|
+
}
|
|
308
|
+
break;
|
|
309
|
+
case "rating":
|
|
310
|
+
for (const line of renderRatingQuestion(core, width, theme)) {
|
|
311
|
+
lines.push(panelLine(line, width));
|
|
312
|
+
}
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
lines.push(panelLine("", width));
|
|
317
|
+
|
|
318
|
+
if (core.errorMessage) {
|
|
319
|
+
lines.push(
|
|
320
|
+
panelLine(
|
|
321
|
+
theme.fg("warning", ` ⚠ ${core.errorMessage}`),
|
|
322
|
+
width,
|
|
323
|
+
),
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const help = renderHelpBar(q, isMultiQuestion, core.inputMode, theme);
|
|
329
|
+
lines.push(panelLine(help, width));
|
|
330
|
+
|
|
331
|
+
lines.push(panelBottom(width, theme));
|
|
332
|
+
|
|
333
|
+
cachedLines = lines;
|
|
334
|
+
return lines;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return { render, invalidate: () => { cachedLines = undefined; }, handleInput };
|
|
338
|
+
},
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
if ('cancelled' in result) {
|
|
342
|
+
return {
|
|
343
|
+
content: [{ type: "text", text: result.message }],
|
|
344
|
+
details: result,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const answerLines = Object.entries(result.answers).flatMap(([id, a]) => {
|
|
349
|
+
const q = core.questions.find((q) => q.id === id) || findQuestion(originalQuestions, id);
|
|
350
|
+
const qLabel = q?.label || id;
|
|
351
|
+
if ('text' in a) {
|
|
352
|
+
return [`${qLabel}: ${a.text}`];
|
|
353
|
+
}
|
|
354
|
+
if ('confirmed' in a) {
|
|
355
|
+
return [`${qLabel}: ${a.label}`];
|
|
356
|
+
}
|
|
357
|
+
if ('value' in a && typeof a.value === 'number') {
|
|
358
|
+
return [`${qLabel}: ${a.value}${a.annotation ? ` (${a.annotation})` : ''}`];
|
|
359
|
+
}
|
|
360
|
+
if ('value' in a && typeof a.value === 'string') {
|
|
361
|
+
if (a.wasCustom) return [`${qLabel}: (自定义) ${a.label}`];
|
|
362
|
+
return [`${qLabel}: ${a.label}`];
|
|
363
|
+
}
|
|
364
|
+
if ('values' in a) {
|
|
365
|
+
if (a.labels.length === 0) return [`${qLabel}: (未回答)`];
|
|
366
|
+
const parts = a.labels.join(', ');
|
|
367
|
+
if (a.wasCustom) return [`${qLabel}: ${parts} · (自定义)`];
|
|
368
|
+
return [`${qLabel}: ${parts}`];
|
|
369
|
+
}
|
|
370
|
+
return [`${qLabel}: (未回答)`];
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
content: [{ type: "text", text: answerLines.join("\n") }],
|
|
375
|
+
details: result,
|
|
376
|
+
};
|
|
377
|
+
},
|
|
378
|
+
|
|
379
|
+
renderCall(args, theme, _context) {
|
|
380
|
+
return renderCallText(
|
|
381
|
+
args as { questions?: Array<{ label?: string; id: string }> },
|
|
382
|
+
theme,
|
|
383
|
+
);
|
|
384
|
+
},
|
|
385
|
+
|
|
386
|
+
renderResult(result, _options, theme, _context) {
|
|
387
|
+
return renderResultText(
|
|
388
|
+
result as {
|
|
389
|
+
content: Array<{ type: string; text: string }>;
|
|
390
|
+
details: unknown;
|
|
391
|
+
},
|
|
392
|
+
theme,
|
|
393
|
+
);
|
|
394
|
+
},
|
|
395
|
+
});
|
|
396
|
+
}
|