@springbrand/message-panel 0.2.0-alpha.49 → 0.2.0-alpha.50
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/cloud-os/chat/rich-blocks.tsx +51 -44
- package/cloud-os/chat/tool-presentation.ts +52 -52
- package/cloud-os/file-view.tsx +1 -1
- package/demo/chat-scenarios.ts +7 -5
- package/demo/fixtures.ts +2 -1
- package/package.json +1 -1
- package/src/camel/camel-chat-messages.tsx +31 -8
- package/src/parts/index.tsx +100 -62
|
@@ -21,7 +21,7 @@ import {
|
|
|
21
21
|
CollapsibleTrigger,
|
|
22
22
|
} from "../primitives/collapsible";
|
|
23
23
|
import { Tooltip } from "../primitives/tooltip";
|
|
24
|
-
import { ImagePreview } from "../file-view";
|
|
24
|
+
import { fileMeta, ImagePreview } from "../file-view";
|
|
25
25
|
import { WorkshopButton } from "../primitives/workshop-controls";
|
|
26
26
|
import { MarkdownMessage } from "./markdown-message";
|
|
27
27
|
import { collapsePanel } from "./surfaces";
|
|
@@ -145,9 +145,8 @@ export function PlanBlock({
|
|
|
145
145
|
interface AskQuestion {
|
|
146
146
|
prompt: string;
|
|
147
147
|
options: string[];
|
|
148
|
-
multi: boolean;
|
|
149
148
|
allowCustom: boolean;
|
|
150
|
-
|
|
149
|
+
responseType: "text" | "single_select" | "multi_select" | "attachment";
|
|
151
150
|
}
|
|
152
151
|
|
|
153
152
|
function questionsOf(input: Record<string, unknown>): AskQuestion[] {
|
|
@@ -159,14 +158,25 @@ function questionsOf(input: Record<string, unknown>): AskQuestion[] {
|
|
|
159
158
|
typeof option === "string"
|
|
160
159
|
)
|
|
161
160
|
: [];
|
|
161
|
+
const responseType = question.responseType === "text" ||
|
|
162
|
+
question.responseType === "single_select" ||
|
|
163
|
+
question.responseType === "multi_select" ||
|
|
164
|
+
question.responseType === "attachment"
|
|
165
|
+
? question.responseType
|
|
166
|
+
: question.kind === "attachment"
|
|
167
|
+
? "attachment"
|
|
168
|
+
: options.length === 0
|
|
169
|
+
? "text"
|
|
170
|
+
: question.multiSelect === true
|
|
171
|
+
? "multi_select"
|
|
172
|
+
: "single_select";
|
|
162
173
|
return {
|
|
163
174
|
prompt: typeof question.question === "string"
|
|
164
175
|
? question.question
|
|
165
176
|
: "Choose an option",
|
|
166
177
|
options,
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
kind: question.kind === "attachment" ? "attachment" : "ordinary",
|
|
178
|
+
allowCustom: responseType === "text" || question.allowCustom === true,
|
|
179
|
+
responseType,
|
|
170
180
|
};
|
|
171
181
|
});
|
|
172
182
|
}
|
|
@@ -199,13 +209,6 @@ export interface AskUserBlockProps {
|
|
|
199
209
|
attachmentField?: AskUserAttachmentController;
|
|
200
210
|
}
|
|
201
211
|
|
|
202
|
-
function formatAttachmentSize(size: number | undefined): string {
|
|
203
|
-
if (size === undefined) return "Ready";
|
|
204
|
-
if (size < 1_024) return `${size} B`;
|
|
205
|
-
if (size < 1_024 * 1_024) return `${Math.round(size / 1_024)} KB`;
|
|
206
|
-
return `${(size / 1_024 / 1_024).toFixed(1)} MB`;
|
|
207
|
-
}
|
|
208
|
-
|
|
209
212
|
function AttachmentImage({ src }: { src: string }) {
|
|
210
213
|
const [failed, setFailed] = useState(false);
|
|
211
214
|
useEffect(() => setFailed(false), [src]);
|
|
@@ -224,8 +227,6 @@ function AttachmentImage({ src }: { src: string }) {
|
|
|
224
227
|
export function AskUserAttachmentField({
|
|
225
228
|
attachments,
|
|
226
229
|
disabled,
|
|
227
|
-
busy,
|
|
228
|
-
hasFailure,
|
|
229
230
|
onFilesSelected,
|
|
230
231
|
onRemove,
|
|
231
232
|
onRetry,
|
|
@@ -272,7 +273,7 @@ export function AskUserAttachmentField({
|
|
|
272
273
|
addFiles(files);
|
|
273
274
|
}
|
|
274
275
|
}}
|
|
275
|
-
className="flex min-h-16 w-full items-center justify-center gap-2 rounded-xl border border-dashed border-kumo-line bg-kumo-elevated/50 px-4 py-3 text-[13px] text-kumo-subtle transition-colors hover:
|
|
276
|
+
className="flex min-h-16 w-full items-center justify-center gap-2 rounded-xl border border-dashed border-kumo-line bg-kumo-elevated/50 px-4 py-3 text-[13px] text-kumo-subtle transition-colors hover:bg-kumo-tint/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/40 disabled:cursor-default disabled:opacity-60"
|
|
276
277
|
>
|
|
277
278
|
<UploadSimple size={18} aria-hidden="true" />
|
|
278
279
|
<span>Drop files here or choose files</span>
|
|
@@ -287,7 +288,7 @@ export function AskUserAttachmentField({
|
|
|
287
288
|
? `Uploading ${Math.round((attachment.progress ?? 0) * 100)}%`
|
|
288
289
|
: attachment.status === "error"
|
|
289
290
|
? "Could not upload this file. Try again."
|
|
290
|
-
:
|
|
291
|
+
: fileMeta({ size: attachment.size });
|
|
291
292
|
const preview = (
|
|
292
293
|
<span
|
|
293
294
|
className="grid size-10 shrink-0 place-items-center overflow-hidden rounded-lg bg-kumo-control text-kumo-inactive themed-thumbnail-shadow"
|
|
@@ -365,11 +366,6 @@ export function AskUserAttachmentField({
|
|
|
365
366
|
})}
|
|
366
367
|
</div>
|
|
367
368
|
)}
|
|
368
|
-
{(busy || hasFailure) && attachments.length === 0 && (
|
|
369
|
-
<span role={hasFailure ? "alert" : "status"} className="text-[11px] text-kumo-inactive">
|
|
370
|
-
{hasFailure ? "Could not upload this file. Try again." : "Uploading"}
|
|
371
|
-
</span>
|
|
372
|
-
)}
|
|
373
369
|
</div>
|
|
374
370
|
);
|
|
375
371
|
}
|
|
@@ -386,7 +382,7 @@ export function AskUserBlock({
|
|
|
386
382
|
const [answers, setAnswers] = useState<DraftAnswer[]>([]);
|
|
387
383
|
const answerAt = (index: number): DraftAnswer =>
|
|
388
384
|
answers[index] ?? { selections: [], text: "" };
|
|
389
|
-
const toggle = (questionIndex: number, option: string
|
|
385
|
+
const toggle = (questionIndex: number, option: string) =>
|
|
390
386
|
setAnswers((current) => {
|
|
391
387
|
const next = questions.map((_, index) =>
|
|
392
388
|
current[index] ?? { selections: [], text: "" }
|
|
@@ -394,11 +390,11 @@ export function AskUserBlock({
|
|
|
394
390
|
const answer = next[questionIndex]!;
|
|
395
391
|
next[questionIndex] = {
|
|
396
392
|
...answer,
|
|
397
|
-
selections:
|
|
398
|
-
?
|
|
399
|
-
: answer.selections.includes(option)
|
|
393
|
+
selections: questions[questionIndex]?.responseType === "multi_select"
|
|
394
|
+
? answer.selections.includes(option)
|
|
400
395
|
? answer.selections.filter((value) => value !== option)
|
|
401
|
-
: [...answer.selections, option]
|
|
396
|
+
: [...answer.selections, option]
|
|
397
|
+
: [option],
|
|
402
398
|
};
|
|
403
399
|
return next;
|
|
404
400
|
});
|
|
@@ -416,7 +412,7 @@ export function AskUserBlock({
|
|
|
416
412
|
const outcome = askUserOutcome(call);
|
|
417
413
|
const responseTimerRef = useRef<number | null>(null);
|
|
418
414
|
const hasAttachmentQuestion = questions.some(
|
|
419
|
-
(question) => question.
|
|
415
|
+
(question) => question.responseType === "attachment",
|
|
420
416
|
);
|
|
421
417
|
const clearResponseTimer = () => {
|
|
422
418
|
if (responseTimerRef.current === null) return;
|
|
@@ -430,7 +426,7 @@ export function AskUserBlock({
|
|
|
430
426
|
const displayAnswerAt = (index: number): DraftAnswer => {
|
|
431
427
|
if (outcome.kind !== "answered") return answerAt(index);
|
|
432
428
|
const answer = outcome.answers[index];
|
|
433
|
-
if (!answer
|
|
429
|
+
if (!answer) {
|
|
434
430
|
return { selections: [], text: "" };
|
|
435
431
|
}
|
|
436
432
|
return {
|
|
@@ -441,14 +437,17 @@ export function AskUserBlock({
|
|
|
441
437
|
// 服务端已经结算 = 这张卡永久不可操作,不管结算成什么。
|
|
442
438
|
const resolved = outcome.kind !== "pending" || inFlight;
|
|
443
439
|
const valid = questions.length > 0 && questions.every(
|
|
444
|
-
(question, index) => question.
|
|
440
|
+
(question, index) => question.responseType === "attachment"
|
|
445
441
|
? Boolean(
|
|
446
442
|
attachmentField &&
|
|
447
443
|
attachmentField.attachments.length > 0 &&
|
|
448
444
|
!attachmentField.busy &&
|
|
449
445
|
!attachmentField.hasFailure
|
|
450
446
|
)
|
|
451
|
-
:
|
|
447
|
+
: question.responseType === "text"
|
|
448
|
+
? Boolean(answerAt(index).text.trim())
|
|
449
|
+
: answerAt(index).selections.length > 0 ||
|
|
450
|
+
(question.allowCustom && Boolean(answerAt(index).text.trim())),
|
|
452
451
|
);
|
|
453
452
|
const buttonLabel = inFlight ? "Submitting…" : "Submit";
|
|
454
453
|
const footnote = outcome.kind === "closed"
|
|
@@ -463,12 +462,17 @@ export function AskUserBlock({
|
|
|
463
462
|
try {
|
|
464
463
|
const response = {
|
|
465
464
|
answers: questions.map((question, index) =>
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
465
|
+
({
|
|
466
|
+
selections: question.responseType === "attachment"
|
|
467
|
+
? []
|
|
468
|
+
: answerAt(index).selections,
|
|
469
|
+
text: question.responseType === "attachment"
|
|
470
|
+
? ""
|
|
471
|
+
: answerAt(index).text.trim(),
|
|
472
|
+
attachments: question.responseType === "attachment"
|
|
473
|
+
? [...(attachmentField?.getParts() ?? [])]
|
|
474
|
+
: [],
|
|
475
|
+
})
|
|
472
476
|
),
|
|
473
477
|
};
|
|
474
478
|
setInFlight(true);
|
|
@@ -512,6 +516,8 @@ export function AskUserBlock({
|
|
|
512
516
|
const options = outcome.kind === "answered"
|
|
513
517
|
? [...new Set([...question.options, ...answer.selections])]
|
|
514
518
|
: question.options;
|
|
519
|
+
const choice = question.responseType === "single_select" ||
|
|
520
|
+
question.responseType === "multi_select";
|
|
515
521
|
return (
|
|
516
522
|
<div
|
|
517
523
|
key={`${question.prompt}:${questionIndex}`}
|
|
@@ -520,10 +526,10 @@ export function AskUserBlock({
|
|
|
520
526
|
<p className="m-0 text-[14px] leading-[1.4] font-normal text-kumo-default">
|
|
521
527
|
{question.prompt}
|
|
522
528
|
</p>
|
|
523
|
-
{question.
|
|
529
|
+
{question.responseType === "attachment" && (
|
|
524
530
|
<AskUserAttachmentField
|
|
525
531
|
attachments={
|
|
526
|
-
settledAttachmentAnswer
|
|
532
|
+
settledAttachmentAnswer
|
|
527
533
|
? settledAttachmentAnswer.attachments.map((attachment) => ({
|
|
528
534
|
id: attachment.attachmentId,
|
|
529
535
|
filename: attachment.name,
|
|
@@ -542,7 +548,7 @@ export function AskUserBlock({
|
|
|
542
548
|
onRetry={attachmentField?.onRetry ?? (() => undefined)}
|
|
543
549
|
/>
|
|
544
550
|
)}
|
|
545
|
-
{
|
|
551
|
+
{choice && options.length > 0 && (
|
|
546
552
|
<div className="flex flex-wrap items-center gap-2">
|
|
547
553
|
{options.map((option) => {
|
|
548
554
|
const selected = answer.selections.includes(option);
|
|
@@ -552,8 +558,7 @@ export function AskUserBlock({
|
|
|
552
558
|
type="button"
|
|
553
559
|
aria-pressed={selected}
|
|
554
560
|
disabled={resolved || !onRespond}
|
|
555
|
-
onClick={() =>
|
|
556
|
-
toggle(questionIndex, option, question.multi)}
|
|
561
|
+
onClick={() => toggle(questionIndex, option)}
|
|
557
562
|
style={selected
|
|
558
563
|
? {
|
|
559
564
|
background:
|
|
@@ -574,7 +579,8 @@ export function AskUserBlock({
|
|
|
574
579
|
})}
|
|
575
580
|
</div>
|
|
576
581
|
)}
|
|
577
|
-
{question.
|
|
582
|
+
{(question.responseType === "text" ||
|
|
583
|
+
(choice && question.allowCustom)) && (
|
|
578
584
|
<label className="flex w-full flex-col gap-2 text-[14px] leading-[1.4] text-kumo-default">
|
|
579
585
|
<span>{question.options.length > 0 ? "Other" : "Answer"}</span>
|
|
580
586
|
<input
|
|
@@ -592,7 +598,8 @@ export function AskUserBlock({
|
|
|
592
598
|
/>
|
|
593
599
|
</label>
|
|
594
600
|
)}
|
|
595
|
-
{question.
|
|
601
|
+
{question.responseType !== "attachment" &&
|
|
602
|
+
outcome.kind === "answered" &&
|
|
596
603
|
!question.allowCustom && answer.text.trim() && (
|
|
597
604
|
<p className="m-0 text-[14px] leading-[1.4] text-kumo-subtle">
|
|
598
605
|
{answer.text.trim()}
|
|
@@ -503,25 +503,20 @@ export type AskUserOutcome =
|
|
|
503
503
|
| { kind: "pending" }
|
|
504
504
|
| {
|
|
505
505
|
kind: "answered";
|
|
506
|
-
answers: Array<
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
attachmentId: string;
|
|
521
|
-
contentVersion: string;
|
|
522
|
-
}>;
|
|
523
|
-
}
|
|
524
|
-
>;
|
|
506
|
+
answers: Array<{
|
|
507
|
+
question: string;
|
|
508
|
+
selections: string[];
|
|
509
|
+
text: string;
|
|
510
|
+
attachments: Array<{
|
|
511
|
+
name: string;
|
|
512
|
+
mediaType: string;
|
|
513
|
+
size: number;
|
|
514
|
+
cdnUrl: string;
|
|
515
|
+
workspacePath: string;
|
|
516
|
+
attachmentId: string;
|
|
517
|
+
contentVersion: string;
|
|
518
|
+
}>;
|
|
519
|
+
}>;
|
|
525
520
|
}
|
|
526
521
|
| { kind: "closed"; reason: "user_replied_freeform" | "other" };
|
|
527
522
|
|
|
@@ -534,47 +529,52 @@ export function askUserOutcome(call: CloudOsToolCall): AskUserOutcome {
|
|
|
534
529
|
const answers = record.answers.map(recordOf);
|
|
535
530
|
if (answers.every((answer) => {
|
|
536
531
|
if (typeof answer.question !== "string") return false;
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
532
|
+
const selections = answer.selections;
|
|
533
|
+
const attachments = answer.attachments;
|
|
534
|
+
if (!Array.isArray(selections) && !Array.isArray(attachments)) return false;
|
|
535
|
+
if (
|
|
536
|
+
Array.isArray(selections) &&
|
|
537
|
+
!selections.every((value) => typeof value === "string")
|
|
538
|
+
) return false;
|
|
539
|
+
if (answer.text !== undefined && typeof answer.text !== "string") return false;
|
|
540
|
+
if (Array.isArray(attachments)) {
|
|
541
|
+
if (!Array.isArray(selections) && attachments.length === 0) return false;
|
|
542
|
+
return attachments.every((value) => {
|
|
543
|
+
const attachment = recordOf(value);
|
|
544
|
+
return typeof attachment.name === "string" &&
|
|
545
|
+
typeof attachment.mediaType === "string" &&
|
|
546
|
+
typeof attachment.size === "number" &&
|
|
547
|
+
typeof attachment.cdnUrl === "string" &&
|
|
548
|
+
typeof attachment.workspacePath === "string" &&
|
|
549
|
+
typeof attachment.attachmentId === "string" &&
|
|
550
|
+
typeof attachment.contentVersion === "string";
|
|
551
|
+
});
|
|
549
552
|
}
|
|
550
|
-
return
|
|
551
|
-
answer.selections.every((value) => typeof value === "string") &&
|
|
552
|
-
(answer.text === undefined || typeof answer.text === "string");
|
|
553
|
+
return true;
|
|
553
554
|
})) {
|
|
554
555
|
return {
|
|
555
556
|
kind: "answered",
|
|
556
557
|
answers: answers.map((answer) => {
|
|
557
|
-
if (Array.isArray(answer.attachments)) {
|
|
558
|
-
return {
|
|
559
|
-
question: answer.question as string,
|
|
560
|
-
attachments: answer.attachments.map((value) => {
|
|
561
|
-
const attachment = recordOf(value);
|
|
562
|
-
return {
|
|
563
|
-
name: attachment.name as string,
|
|
564
|
-
mediaType: attachment.mediaType as string,
|
|
565
|
-
size: attachment.size as number,
|
|
566
|
-
cdnUrl: attachment.cdnUrl as string,
|
|
567
|
-
workspacePath: attachment.workspacePath as string,
|
|
568
|
-
attachmentId: attachment.attachmentId as string,
|
|
569
|
-
contentVersion: attachment.contentVersion as string,
|
|
570
|
-
};
|
|
571
|
-
}),
|
|
572
|
-
};
|
|
573
|
-
}
|
|
574
558
|
return {
|
|
575
559
|
question: answer.question as string,
|
|
576
|
-
selections: answer.selections
|
|
560
|
+
selections: Array.isArray(answer.selections)
|
|
561
|
+
? answer.selections as string[]
|
|
562
|
+
: [],
|
|
577
563
|
text: typeof answer.text === "string" ? answer.text : "",
|
|
564
|
+
attachments: Array.isArray(answer.attachments)
|
|
565
|
+
? answer.attachments.map((value) => {
|
|
566
|
+
const attachment = recordOf(value);
|
|
567
|
+
return {
|
|
568
|
+
name: attachment.name as string,
|
|
569
|
+
mediaType: attachment.mediaType as string,
|
|
570
|
+
size: attachment.size as number,
|
|
571
|
+
cdnUrl: attachment.cdnUrl as string,
|
|
572
|
+
workspacePath: attachment.workspacePath as string,
|
|
573
|
+
attachmentId: attachment.attachmentId as string,
|
|
574
|
+
contentVersion: attachment.contentVersion as string,
|
|
575
|
+
};
|
|
576
|
+
})
|
|
577
|
+
: [],
|
|
578
578
|
};
|
|
579
579
|
}),
|
|
580
580
|
};
|
package/cloud-os/file-view.tsx
CHANGED
|
@@ -70,7 +70,7 @@ export interface FileViewProps {
|
|
|
70
70
|
|
|
71
71
|
export type FileRenderer = ComponentType<FileViewProps>;
|
|
72
72
|
|
|
73
|
-
function fileMeta(file: FileViewFile): string {
|
|
73
|
+
export function fileMeta(file: FileViewFile): string {
|
|
74
74
|
if (file.size === undefined) return "Ready";
|
|
75
75
|
if (file.size < 1024) return `${file.size} B`;
|
|
76
76
|
if (file.size < 1024 * 1024) return `${Math.round(file.size / 1024)} KB`;
|
package/demo/chat-scenarios.ts
CHANGED
|
@@ -266,25 +266,26 @@ function actionUserMessage(prompt: string): UIMessage {
|
|
|
266
266
|
}
|
|
267
267
|
|
|
268
268
|
export interface DemoAskUserResponse {
|
|
269
|
-
answers: Array<{ selections: string[]; text
|
|
269
|
+
answers: Array<{ selections: string[]; text: string; attachments: [] }>;
|
|
270
270
|
}
|
|
271
271
|
|
|
272
272
|
export const demoAskUserResponse: DemoAskUserResponse = {
|
|
273
273
|
answers: [
|
|
274
|
-
{ selections: ["管理层"], text: "" },
|
|
275
|
-
{ selections: ["报告"], text: "" },
|
|
274
|
+
{ selections: ["管理层"], text: "", attachments: [] },
|
|
275
|
+
{ selections: ["报告"], text: "", attachments: [] },
|
|
276
276
|
],
|
|
277
277
|
};
|
|
278
278
|
|
|
279
279
|
const askUserQuestions = [
|
|
280
280
|
{
|
|
281
281
|
question: "这份建议优先面向哪个读者?",
|
|
282
|
+
responseType: "single_select",
|
|
282
283
|
options: ["产品团队", "管理层", "设计团队"],
|
|
283
284
|
},
|
|
284
285
|
{
|
|
285
286
|
question: "需要哪些交付物?",
|
|
287
|
+
responseType: "multi_select",
|
|
286
288
|
options: ["报告", "演示文稿", "执行清单"],
|
|
287
|
-
multiSelect: true,
|
|
288
289
|
allowCustom: true,
|
|
289
290
|
},
|
|
290
291
|
];
|
|
@@ -305,7 +306,8 @@ function askUserRequest(response?: DemoAskUserResponse): UIMessage {
|
|
|
305
306
|
answers: response.answers.map((answer, index) => ({
|
|
306
307
|
question: askUserQuestions[index]!.question,
|
|
307
308
|
selections: answer.selections,
|
|
308
|
-
text: answer.text
|
|
309
|
+
text: answer.text.trim(),
|
|
310
|
+
attachments: answer.attachments,
|
|
309
311
|
})),
|
|
310
312
|
},
|
|
311
313
|
}
|
package/demo/fixtures.ts
CHANGED
|
@@ -167,12 +167,13 @@ export const allPartsMessage: DemoMessage = {
|
|
|
167
167
|
questions: [
|
|
168
168
|
{
|
|
169
169
|
question: "报告通过什么渠道发送?",
|
|
170
|
+
responseType: "single_select",
|
|
170
171
|
options: ["Email", "Slack", "Both"],
|
|
171
172
|
},
|
|
172
173
|
{
|
|
173
174
|
question: "需要包含哪些内容?",
|
|
175
|
+
responseType: "multi_select",
|
|
174
176
|
options: ["定价", "功能", "动态"],
|
|
175
|
-
multiSelect: true,
|
|
176
177
|
allowCustom: true,
|
|
177
178
|
},
|
|
178
179
|
],
|
package/package.json
CHANGED
|
@@ -666,6 +666,20 @@ function CamelSchedule({
|
|
|
666
666
|
);
|
|
667
667
|
}
|
|
668
668
|
|
|
669
|
+
function askUserResponseType(question: Record<string, unknown>) {
|
|
670
|
+
if (
|
|
671
|
+
question.responseType === "text" ||
|
|
672
|
+
question.responseType === "single_select" ||
|
|
673
|
+
question.responseType === "multi_select" ||
|
|
674
|
+
question.responseType === "attachment"
|
|
675
|
+
) return question.responseType;
|
|
676
|
+
if (question.kind === "attachment") return "attachment";
|
|
677
|
+
if (!Array.isArray(question.options) || question.options.length === 0) {
|
|
678
|
+
return "text";
|
|
679
|
+
}
|
|
680
|
+
return question.multiSelect === true ? "multi_select" : "single_select";
|
|
681
|
+
}
|
|
682
|
+
|
|
669
683
|
function CamelAskUser({
|
|
670
684
|
part,
|
|
671
685
|
onAnswer,
|
|
@@ -689,9 +703,13 @@ function CamelAskUser({
|
|
|
689
703
|
const answerAt = (index: number) =>
|
|
690
704
|
answers[index] ?? { selections: [], text: "" };
|
|
691
705
|
const resolvedSubmitted = settledAnswers.length > 0;
|
|
692
|
-
const valid = questions.length > 0 && questions.every((
|
|
693
|
-
|
|
694
|
-
|
|
706
|
+
const valid = questions.length > 0 && questions.every((question, index) => {
|
|
707
|
+
const responseType = askUserResponseType(question);
|
|
708
|
+
if (responseType === "attachment") return false;
|
|
709
|
+
if (responseType === "text") return Boolean(answerAt(index).text.trim());
|
|
710
|
+
return answerAt(index).selections.length > 0 ||
|
|
711
|
+
(question.allowCustom === true && Boolean(answerAt(index).text.trim()));
|
|
712
|
+
});
|
|
695
713
|
|
|
696
714
|
return (
|
|
697
715
|
<section
|
|
@@ -701,14 +719,18 @@ function CamelAskUser({
|
|
|
701
719
|
>
|
|
702
720
|
{questions.map((question, questionIndex) => {
|
|
703
721
|
const prompt = String(question.question ?? "Choose an option");
|
|
704
|
-
const
|
|
722
|
+
const responseType = askUserResponseType(question);
|
|
723
|
+
const attachmentQuestion = responseType === "attachment";
|
|
724
|
+
const choiceQuestion = responseType === "single_select" ||
|
|
725
|
+
responseType === "multi_select";
|
|
705
726
|
const options = Array.isArray(question.options)
|
|
706
727
|
? question.options.filter(
|
|
707
728
|
(option): option is string => typeof option === "string",
|
|
708
729
|
)
|
|
709
730
|
: [];
|
|
710
|
-
const multi =
|
|
711
|
-
const allowCustom =
|
|
731
|
+
const multi = responseType === "multi_select";
|
|
732
|
+
const allowCustom = responseType === "text" ||
|
|
733
|
+
(choiceQuestion && question.allowCustom === true);
|
|
712
734
|
const settledAnswer = settledAnswers[questionIndex];
|
|
713
735
|
const settledSelections = Array.isArray(settledAnswer?.selections)
|
|
714
736
|
? settledAnswer.selections.filter(
|
|
@@ -749,7 +771,7 @@ function CamelAskUser({
|
|
|
749
771
|
))}
|
|
750
772
|
</div>
|
|
751
773
|
)}
|
|
752
|
-
{
|
|
774
|
+
{choiceQuestion && <div className="flex flex-wrap gap-2">
|
|
753
775
|
{options.map((option) => {
|
|
754
776
|
const selected = settledAnswer
|
|
755
777
|
? settledSelections.includes(option)
|
|
@@ -821,13 +843,14 @@ function CamelAskUser({
|
|
|
821
843
|
type="button"
|
|
822
844
|
size="sm"
|
|
823
845
|
disabled={resolvedSubmitted || !valid || questions.some((question) =>
|
|
824
|
-
question
|
|
846
|
+
askUserResponseType(question) === "attachment"
|
|
825
847
|
)}
|
|
826
848
|
onClick={() => {
|
|
827
849
|
onAnswer({
|
|
828
850
|
answers: questions.map((_, index) => ({
|
|
829
851
|
selections: answerAt(index).selections,
|
|
830
852
|
text: answerAt(index).text.trim(),
|
|
853
|
+
attachments: [],
|
|
831
854
|
})),
|
|
832
855
|
});
|
|
833
856
|
}}
|
package/src/parts/index.tsx
CHANGED
|
@@ -834,20 +834,37 @@ export function DashboardPart(data: DashboardData) {
|
|
|
834
834
|
|
|
835
835
|
export interface AskQuestion {
|
|
836
836
|
question: string;
|
|
837
|
+
responseType: "text" | "single_select" | "multi_select" | "attachment";
|
|
837
838
|
options?: string[];
|
|
838
|
-
multiSelect?: boolean;
|
|
839
839
|
allowCustom?: boolean;
|
|
840
840
|
}
|
|
841
841
|
|
|
842
|
+
function askQuestionResponseType(question: AskQuestion) {
|
|
843
|
+
if (question.responseType) return question.responseType;
|
|
844
|
+
const legacy = question as AskQuestion & {
|
|
845
|
+
kind?: string;
|
|
846
|
+
multiSelect?: boolean;
|
|
847
|
+
};
|
|
848
|
+
if (legacy.kind === "attachment") return "attachment";
|
|
849
|
+
if (!question.options?.length) return "text";
|
|
850
|
+
return legacy.multiSelect ? "multi_select" : "single_select";
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
interface AskAnswer {
|
|
854
|
+
selections: string[];
|
|
855
|
+
text: string;
|
|
856
|
+
attachments: unknown[];
|
|
857
|
+
}
|
|
858
|
+
|
|
842
859
|
export function AskUserPart({
|
|
843
860
|
questions,
|
|
844
861
|
submittedAnswers = [],
|
|
845
862
|
onSubmit,
|
|
846
863
|
}: {
|
|
847
864
|
questions: readonly AskQuestion[];
|
|
848
|
-
submittedAnswers?: ReadonlyArray<
|
|
865
|
+
submittedAnswers?: ReadonlyArray<AskAnswer>;
|
|
849
866
|
onSubmit?: (payload: {
|
|
850
|
-
answers:
|
|
867
|
+
answers: AskAnswer[];
|
|
851
868
|
}) => void | Promise<void>;
|
|
852
869
|
}) {
|
|
853
870
|
const [answers, setAnswers] = useState<Array<{
|
|
@@ -859,8 +876,13 @@ export function AskUserPart({
|
|
|
859
876
|
const [inFlight, setInFlight] = useState(false);
|
|
860
877
|
const resolvedSubmitted = submittedAnswers.length > 0;
|
|
861
878
|
const valid = questions.length > 0 && questions.every(
|
|
862
|
-
(
|
|
863
|
-
|
|
879
|
+
(question, index) => {
|
|
880
|
+
const responseType = askQuestionResponseType(question);
|
|
881
|
+
if (responseType === "attachment") return false;
|
|
882
|
+
if (responseType === "text") return Boolean(answerAt(index).text.trim());
|
|
883
|
+
return answerAt(index).selections.length > 0 ||
|
|
884
|
+
(question.allowCustom === true && Boolean(answerAt(index).text.trim()));
|
|
885
|
+
},
|
|
864
886
|
);
|
|
865
887
|
const toggle = (questionIndex: number, option: string) => {
|
|
866
888
|
if (resolvedSubmitted || inFlight || !onSubmit) return;
|
|
@@ -871,7 +893,8 @@ export function AskUserPart({
|
|
|
871
893
|
const answer = next[questionIndex]!;
|
|
872
894
|
next[questionIndex] = {
|
|
873
895
|
...answer,
|
|
874
|
-
selections: questions[questionIndex]
|
|
896
|
+
selections: askQuestionResponseType(questions[questionIndex]!) ===
|
|
897
|
+
"multi_select"
|
|
875
898
|
? answer.selections.includes(option)
|
|
876
899
|
? answer.selections.filter((item) => item !== option)
|
|
877
900
|
: [...answer.selections, option]
|
|
@@ -888,6 +911,7 @@ export function AskUserPart({
|
|
|
888
911
|
answers: questions.map((_, index) => ({
|
|
889
912
|
selections: answerAt(index).selections,
|
|
890
913
|
text: answerAt(index).text.trim(),
|
|
914
|
+
attachments: [],
|
|
891
915
|
})),
|
|
892
916
|
});
|
|
893
917
|
} catch {
|
|
@@ -900,63 +924,74 @@ export function AskUserPart({
|
|
|
900
924
|
data-part-type="ask_user"
|
|
901
925
|
data-state={resolvedSubmitted ? "submitted" : "pending"}
|
|
902
926
|
>
|
|
903
|
-
{questions.map((question, questionIndex) =>
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
<
|
|
909
|
-
|
|
910
|
-
{
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
927
|
+
{questions.map((question, questionIndex) => {
|
|
928
|
+
const responseType = askQuestionResponseType(question);
|
|
929
|
+
const choice = responseType === "single_select" ||
|
|
930
|
+
responseType === "multi_select";
|
|
931
|
+
return (
|
|
932
|
+
<fieldset
|
|
933
|
+
key={`${questionIndex}:${question.question}`}
|
|
934
|
+
disabled={resolvedSubmitted || inFlight || !onSubmit}
|
|
935
|
+
>
|
|
936
|
+
<legend>{question.question}</legend>
|
|
937
|
+
<div>
|
|
938
|
+
{choice && (question.options ?? []).map((option) => {
|
|
939
|
+
const selected = submittedAnswers[questionIndex]
|
|
940
|
+
? submittedAnswers[questionIndex]!.selections.includes(option)
|
|
941
|
+
: answerAt(questionIndex).selections.includes(option);
|
|
942
|
+
return (
|
|
943
|
+
<button
|
|
944
|
+
key={option}
|
|
945
|
+
type="button"
|
|
946
|
+
aria-pressed={selected}
|
|
947
|
+
onClick={() => toggle(questionIndex, option)}
|
|
948
|
+
>
|
|
949
|
+
{responseType === "multi_select" &&
|
|
950
|
+
<span>{selected && <CheckIcon size={12} />}</span>}
|
|
951
|
+
{selected && responseType === "single_select" &&
|
|
952
|
+
<CheckIcon size={14} />}
|
|
953
|
+
{option}
|
|
954
|
+
</button>
|
|
955
|
+
);
|
|
956
|
+
})}
|
|
957
|
+
</div>
|
|
958
|
+
{!resolvedSubmitted &&
|
|
959
|
+
(responseType === "text" || question.allowCustom === true) && (
|
|
960
|
+
<textarea
|
|
961
|
+
value={answerAt(questionIndex).text}
|
|
962
|
+
maxLength={2_000}
|
|
963
|
+
aria-label={`Custom answer: ${question.question}`}
|
|
964
|
+
onChange={(event) =>
|
|
965
|
+
setAnswers((current) => {
|
|
966
|
+
const next = questions.map((_, index) =>
|
|
967
|
+
current[index] ?? { selections: [], text: "" }
|
|
968
|
+
);
|
|
969
|
+
next[questionIndex] = {
|
|
970
|
+
...next[questionIndex]!,
|
|
971
|
+
text: event.currentTarget.value,
|
|
972
|
+
};
|
|
973
|
+
return next;
|
|
974
|
+
})}
|
|
975
|
+
placeholder={question.options?.length
|
|
976
|
+
? "Other"
|
|
977
|
+
: "Type your answer"}
|
|
978
|
+
/>
|
|
979
|
+
)}
|
|
980
|
+
{submittedAnswers[questionIndex] && (
|
|
981
|
+
<small>
|
|
982
|
+
{[
|
|
983
|
+
...submittedAnswers[questionIndex]!.selections,
|
|
984
|
+
...(submittedAnswers[questionIndex]!.text?.trim()
|
|
985
|
+
? [submittedAnswers[questionIndex]!.text!.trim()]
|
|
986
|
+
: []),
|
|
987
|
+
].join(" / ")}
|
|
988
|
+
</small>
|
|
947
989
|
)}
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
? [submittedAnswers[questionIndex]!.text!.trim()]
|
|
954
|
-
: []),
|
|
955
|
-
].join(" / ")}
|
|
956
|
-
</small>
|
|
957
|
-
)}
|
|
958
|
-
</fieldset>
|
|
959
|
-
))}
|
|
990
|
+
{responseType === "attachment" && !resolvedSubmitted &&
|
|
991
|
+
<small>File upload is unavailable.</small>}
|
|
992
|
+
</fieldset>
|
|
993
|
+
);
|
|
994
|
+
})}
|
|
960
995
|
{resolvedSubmitted ? (
|
|
961
996
|
<div className="sb-ask-user__submitted">
|
|
962
997
|
<CheckIcon size={15} />
|
|
@@ -1170,6 +1205,9 @@ export function DefaultPartRenderer({
|
|
|
1170
1205
|
return {
|
|
1171
1206
|
selections: strings(value.selections),
|
|
1172
1207
|
text: typeof value.text === "string" ? value.text : "",
|
|
1208
|
+
attachments: Array.isArray(value.attachments)
|
|
1209
|
+
? value.attachments
|
|
1210
|
+
: [],
|
|
1173
1211
|
};
|
|
1174
1212
|
})
|
|
1175
1213
|
: [];
|