@springbrand/message-panel 0.2.0-alpha.49 → 0.2.0-alpha.51
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 +70 -52
- 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 +41 -11
- package/src/parts/index.tsx +108 -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,9 @@ export function PlanBlock({
|
|
|
145
145
|
interface AskQuestion {
|
|
146
146
|
prompt: string;
|
|
147
147
|
options: string[];
|
|
148
|
-
multi: boolean;
|
|
149
148
|
allowCustom: boolean;
|
|
150
|
-
|
|
149
|
+
required: boolean;
|
|
150
|
+
responseType: "text" | "single_select" | "multi_select" | "attachment";
|
|
151
151
|
}
|
|
152
152
|
|
|
153
153
|
function questionsOf(input: Record<string, unknown>): AskQuestion[] {
|
|
@@ -159,14 +159,26 @@ function questionsOf(input: Record<string, unknown>): AskQuestion[] {
|
|
|
159
159
|
typeof option === "string"
|
|
160
160
|
)
|
|
161
161
|
: [];
|
|
162
|
+
const responseType = question.responseType === "text" ||
|
|
163
|
+
question.responseType === "single_select" ||
|
|
164
|
+
question.responseType === "multi_select" ||
|
|
165
|
+
question.responseType === "attachment"
|
|
166
|
+
? question.responseType
|
|
167
|
+
: question.kind === "attachment"
|
|
168
|
+
? "attachment"
|
|
169
|
+
: options.length === 0
|
|
170
|
+
? "text"
|
|
171
|
+
: question.multiSelect === true
|
|
172
|
+
? "multi_select"
|
|
173
|
+
: "single_select";
|
|
162
174
|
return {
|
|
163
175
|
prompt: typeof question.question === "string"
|
|
164
176
|
? question.question
|
|
165
177
|
: "Choose an option",
|
|
166
178
|
options,
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
179
|
+
allowCustom: responseType === "text" || question.allowCustom === true,
|
|
180
|
+
required: question.required === true,
|
|
181
|
+
responseType,
|
|
170
182
|
};
|
|
171
183
|
});
|
|
172
184
|
}
|
|
@@ -199,13 +211,6 @@ export interface AskUserBlockProps {
|
|
|
199
211
|
attachmentField?: AskUserAttachmentController;
|
|
200
212
|
}
|
|
201
213
|
|
|
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
214
|
function AttachmentImage({ src }: { src: string }) {
|
|
210
215
|
const [failed, setFailed] = useState(false);
|
|
211
216
|
useEffect(() => setFailed(false), [src]);
|
|
@@ -224,8 +229,6 @@ function AttachmentImage({ src }: { src: string }) {
|
|
|
224
229
|
export function AskUserAttachmentField({
|
|
225
230
|
attachments,
|
|
226
231
|
disabled,
|
|
227
|
-
busy,
|
|
228
|
-
hasFailure,
|
|
229
232
|
onFilesSelected,
|
|
230
233
|
onRemove,
|
|
231
234
|
onRetry,
|
|
@@ -272,7 +275,7 @@ export function AskUserAttachmentField({
|
|
|
272
275
|
addFiles(files);
|
|
273
276
|
}
|
|
274
277
|
}}
|
|
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:
|
|
278
|
+
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
279
|
>
|
|
277
280
|
<UploadSimple size={18} aria-hidden="true" />
|
|
278
281
|
<span>Drop files here or choose files</span>
|
|
@@ -287,7 +290,7 @@ export function AskUserAttachmentField({
|
|
|
287
290
|
? `Uploading ${Math.round((attachment.progress ?? 0) * 100)}%`
|
|
288
291
|
: attachment.status === "error"
|
|
289
292
|
? "Could not upload this file. Try again."
|
|
290
|
-
:
|
|
293
|
+
: fileMeta({ size: attachment.size });
|
|
291
294
|
const preview = (
|
|
292
295
|
<span
|
|
293
296
|
className="grid size-10 shrink-0 place-items-center overflow-hidden rounded-lg bg-kumo-control text-kumo-inactive themed-thumbnail-shadow"
|
|
@@ -365,11 +368,6 @@ export function AskUserAttachmentField({
|
|
|
365
368
|
})}
|
|
366
369
|
</div>
|
|
367
370
|
)}
|
|
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
371
|
</div>
|
|
374
372
|
);
|
|
375
373
|
}
|
|
@@ -386,7 +384,7 @@ export function AskUserBlock({
|
|
|
386
384
|
const [answers, setAnswers] = useState<DraftAnswer[]>([]);
|
|
387
385
|
const answerAt = (index: number): DraftAnswer =>
|
|
388
386
|
answers[index] ?? { selections: [], text: "" };
|
|
389
|
-
const toggle = (questionIndex: number, option: string
|
|
387
|
+
const toggle = (questionIndex: number, option: string) =>
|
|
390
388
|
setAnswers((current) => {
|
|
391
389
|
const next = questions.map((_, index) =>
|
|
392
390
|
current[index] ?? { selections: [], text: "" }
|
|
@@ -394,11 +392,11 @@ export function AskUserBlock({
|
|
|
394
392
|
const answer = next[questionIndex]!;
|
|
395
393
|
next[questionIndex] = {
|
|
396
394
|
...answer,
|
|
397
|
-
selections:
|
|
398
|
-
?
|
|
399
|
-
: answer.selections.includes(option)
|
|
395
|
+
selections: questions[questionIndex]?.responseType === "multi_select"
|
|
396
|
+
? answer.selections.includes(option)
|
|
400
397
|
? answer.selections.filter((value) => value !== option)
|
|
401
|
-
: [...answer.selections, option]
|
|
398
|
+
: [...answer.selections, option]
|
|
399
|
+
: [option],
|
|
402
400
|
};
|
|
403
401
|
return next;
|
|
404
402
|
});
|
|
@@ -416,7 +414,7 @@ export function AskUserBlock({
|
|
|
416
414
|
const outcome = askUserOutcome(call);
|
|
417
415
|
const responseTimerRef = useRef<number | null>(null);
|
|
418
416
|
const hasAttachmentQuestion = questions.some(
|
|
419
|
-
(question) => question.
|
|
417
|
+
(question) => question.responseType === "attachment",
|
|
420
418
|
);
|
|
421
419
|
const clearResponseTimer = () => {
|
|
422
420
|
if (responseTimerRef.current === null) return;
|
|
@@ -430,7 +428,7 @@ export function AskUserBlock({
|
|
|
430
428
|
const displayAnswerAt = (index: number): DraftAnswer => {
|
|
431
429
|
if (outcome.kind !== "answered") return answerAt(index);
|
|
432
430
|
const answer = outcome.answers[index];
|
|
433
|
-
if (!answer
|
|
431
|
+
if (!answer) {
|
|
434
432
|
return { selections: [], text: "" };
|
|
435
433
|
}
|
|
436
434
|
return {
|
|
@@ -440,16 +438,25 @@ export function AskUserBlock({
|
|
|
440
438
|
};
|
|
441
439
|
// 服务端已经结算 = 这张卡永久不可操作,不管结算成什么。
|
|
442
440
|
const resolved = outcome.kind !== "pending" || inFlight;
|
|
443
|
-
const valid = questions.length > 0 && questions.every(
|
|
444
|
-
(
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
441
|
+
const valid = questions.length > 0 && questions.every((question, index) => {
|
|
442
|
+
const answer = answerAt(index);
|
|
443
|
+
const attachmentCount = question.responseType === "attachment"
|
|
444
|
+
? attachmentField?.attachments.length ?? 0
|
|
445
|
+
: 0;
|
|
446
|
+
if (
|
|
447
|
+
!question.required && attachmentCount === 0 &&
|
|
448
|
+
answer.selections.length === 0 && !answer.text.trim()
|
|
449
|
+
) return true;
|
|
450
|
+
if (question.responseType === "attachment") {
|
|
451
|
+
return Boolean(
|
|
452
|
+
attachmentCount > 0 && attachmentField &&
|
|
453
|
+
!attachmentField.busy && !attachmentField.hasFailure,
|
|
454
|
+
);
|
|
455
|
+
}
|
|
456
|
+
if (question.responseType === "text") return Boolean(answer.text.trim());
|
|
457
|
+
return answer.selections.length > 0 ||
|
|
458
|
+
(question.allowCustom && Boolean(answer.text.trim()));
|
|
459
|
+
});
|
|
453
460
|
const buttonLabel = inFlight ? "Submitting…" : "Submit";
|
|
454
461
|
const footnote = outcome.kind === "closed"
|
|
455
462
|
? outcome.reason === "user_replied_freeform"
|
|
@@ -463,12 +470,17 @@ export function AskUserBlock({
|
|
|
463
470
|
try {
|
|
464
471
|
const response = {
|
|
465
472
|
answers: questions.map((question, index) =>
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
473
|
+
({
|
|
474
|
+
selections: question.responseType === "attachment"
|
|
475
|
+
? []
|
|
476
|
+
: answerAt(index).selections,
|
|
477
|
+
text: question.responseType === "attachment"
|
|
478
|
+
? ""
|
|
479
|
+
: answerAt(index).text.trim(),
|
|
480
|
+
attachments: question.responseType === "attachment"
|
|
481
|
+
? [...(attachmentField?.getParts() ?? [])]
|
|
482
|
+
: [],
|
|
483
|
+
})
|
|
472
484
|
),
|
|
473
485
|
};
|
|
474
486
|
setInFlight(true);
|
|
@@ -512,6 +524,8 @@ export function AskUserBlock({
|
|
|
512
524
|
const options = outcome.kind === "answered"
|
|
513
525
|
? [...new Set([...question.options, ...answer.selections])]
|
|
514
526
|
: question.options;
|
|
527
|
+
const choice = question.responseType === "single_select" ||
|
|
528
|
+
question.responseType === "multi_select";
|
|
515
529
|
return (
|
|
516
530
|
<div
|
|
517
531
|
key={`${question.prompt}:${questionIndex}`}
|
|
@@ -519,11 +533,14 @@ export function AskUserBlock({
|
|
|
519
533
|
>
|
|
520
534
|
<p className="m-0 text-[14px] leading-[1.4] font-normal text-kumo-default">
|
|
521
535
|
{question.prompt}
|
|
536
|
+
{!question.required && (
|
|
537
|
+
<span className="ml-1 text-kumo-inactive">Optional</span>
|
|
538
|
+
)}
|
|
522
539
|
</p>
|
|
523
|
-
{question.
|
|
540
|
+
{question.responseType === "attachment" && (
|
|
524
541
|
<AskUserAttachmentField
|
|
525
542
|
attachments={
|
|
526
|
-
settledAttachmentAnswer
|
|
543
|
+
settledAttachmentAnswer
|
|
527
544
|
? settledAttachmentAnswer.attachments.map((attachment) => ({
|
|
528
545
|
id: attachment.attachmentId,
|
|
529
546
|
filename: attachment.name,
|
|
@@ -542,7 +559,7 @@ export function AskUserBlock({
|
|
|
542
559
|
onRetry={attachmentField?.onRetry ?? (() => undefined)}
|
|
543
560
|
/>
|
|
544
561
|
)}
|
|
545
|
-
{
|
|
562
|
+
{choice && options.length > 0 && (
|
|
546
563
|
<div className="flex flex-wrap items-center gap-2">
|
|
547
564
|
{options.map((option) => {
|
|
548
565
|
const selected = answer.selections.includes(option);
|
|
@@ -552,8 +569,7 @@ export function AskUserBlock({
|
|
|
552
569
|
type="button"
|
|
553
570
|
aria-pressed={selected}
|
|
554
571
|
disabled={resolved || !onRespond}
|
|
555
|
-
onClick={() =>
|
|
556
|
-
toggle(questionIndex, option, question.multi)}
|
|
572
|
+
onClick={() => toggle(questionIndex, option)}
|
|
557
573
|
style={selected
|
|
558
574
|
? {
|
|
559
575
|
background:
|
|
@@ -574,7 +590,8 @@ export function AskUserBlock({
|
|
|
574
590
|
})}
|
|
575
591
|
</div>
|
|
576
592
|
)}
|
|
577
|
-
{question.
|
|
593
|
+
{(question.responseType === "text" ||
|
|
594
|
+
(choice && question.allowCustom)) && (
|
|
578
595
|
<label className="flex w-full flex-col gap-2 text-[14px] leading-[1.4] text-kumo-default">
|
|
579
596
|
<span>{question.options.length > 0 ? "Other" : "Answer"}</span>
|
|
580
597
|
<input
|
|
@@ -592,7 +609,8 @@ export function AskUserBlock({
|
|
|
592
609
|
/>
|
|
593
610
|
</label>
|
|
594
611
|
)}
|
|
595
|
-
{question.
|
|
612
|
+
{question.responseType !== "attachment" &&
|
|
613
|
+
outcome.kind === "answered" &&
|
|
596
614
|
!question.allowCustom && answer.text.trim() && (
|
|
597
615
|
<p className="m-0 text-[14px] leading-[1.4] text-kumo-subtle">
|
|
598
616
|
{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,17 @@ 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
|
+
const answer = answerAt(index);
|
|
709
|
+
if (
|
|
710
|
+
answer.selections.length === 0 && !answer.text.trim()
|
|
711
|
+
) return question.required !== true;
|
|
712
|
+
if (responseType === "attachment") return false;
|
|
713
|
+
if (responseType === "text") return Boolean(answer.text.trim());
|
|
714
|
+
return answer.selections.length > 0 ||
|
|
715
|
+
(question.allowCustom === true && Boolean(answer.text.trim()));
|
|
716
|
+
});
|
|
695
717
|
|
|
696
718
|
return (
|
|
697
719
|
<section
|
|
@@ -701,14 +723,18 @@ function CamelAskUser({
|
|
|
701
723
|
>
|
|
702
724
|
{questions.map((question, questionIndex) => {
|
|
703
725
|
const prompt = String(question.question ?? "Choose an option");
|
|
704
|
-
const
|
|
726
|
+
const responseType = askUserResponseType(question);
|
|
727
|
+
const attachmentQuestion = responseType === "attachment";
|
|
728
|
+
const choiceQuestion = responseType === "single_select" ||
|
|
729
|
+
responseType === "multi_select";
|
|
705
730
|
const options = Array.isArray(question.options)
|
|
706
731
|
? question.options.filter(
|
|
707
732
|
(option): option is string => typeof option === "string",
|
|
708
733
|
)
|
|
709
734
|
: [];
|
|
710
|
-
const multi =
|
|
711
|
-
const allowCustom =
|
|
735
|
+
const multi = responseType === "multi_select";
|
|
736
|
+
const allowCustom = responseType === "text" ||
|
|
737
|
+
(choiceQuestion && question.allowCustom === true);
|
|
712
738
|
const settledAnswer = settledAnswers[questionIndex];
|
|
713
739
|
const settledSelections = Array.isArray(settledAnswer?.selections)
|
|
714
740
|
? settledAnswer.selections.filter(
|
|
@@ -723,7 +749,12 @@ function CamelAskUser({
|
|
|
723
749
|
: [];
|
|
724
750
|
return (
|
|
725
751
|
<div className="space-y-2" key={`${prompt}:${questionIndex}`}>
|
|
726
|
-
<p className="text-sm text-foreground">
|
|
752
|
+
<p className="text-sm text-foreground">
|
|
753
|
+
{prompt}
|
|
754
|
+
{question.required !== true && (
|
|
755
|
+
<span className="ml-1 text-muted-foreground">Optional</span>
|
|
756
|
+
)}
|
|
757
|
+
</p>
|
|
727
758
|
{attachmentQuestion && settledAttachments.length === 0 && (
|
|
728
759
|
<p className="text-xs text-muted-foreground">
|
|
729
760
|
File upload is unavailable in this preview.
|
|
@@ -749,7 +780,7 @@ function CamelAskUser({
|
|
|
749
780
|
))}
|
|
750
781
|
</div>
|
|
751
782
|
)}
|
|
752
|
-
{
|
|
783
|
+
{choiceQuestion && <div className="flex flex-wrap gap-2">
|
|
753
784
|
{options.map((option) => {
|
|
754
785
|
const selected = settledAnswer
|
|
755
786
|
? settledSelections.includes(option)
|
|
@@ -820,14 +851,13 @@ function CamelAskUser({
|
|
|
820
851
|
<Button
|
|
821
852
|
type="button"
|
|
822
853
|
size="sm"
|
|
823
|
-
disabled={resolvedSubmitted || !valid
|
|
824
|
-
question.kind === "attachment"
|
|
825
|
-
)}
|
|
854
|
+
disabled={resolvedSubmitted || !valid}
|
|
826
855
|
onClick={() => {
|
|
827
856
|
onAnswer({
|
|
828
857
|
answers: questions.map((_, index) => ({
|
|
829
858
|
selections: answerAt(index).selections,
|
|
830
859
|
text: answerAt(index).text.trim(),
|
|
860
|
+
attachments: [],
|
|
831
861
|
})),
|
|
832
862
|
});
|
|
833
863
|
}}
|
package/src/parts/index.tsx
CHANGED
|
@@ -834,9 +834,27 @@ 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
|
+
required?: boolean;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function askQuestionResponseType(question: AskQuestion) {
|
|
844
|
+
if (question.responseType) return question.responseType;
|
|
845
|
+
const legacy = question as AskQuestion & {
|
|
846
|
+
kind?: string;
|
|
847
|
+
multiSelect?: boolean;
|
|
848
|
+
};
|
|
849
|
+
if (legacy.kind === "attachment") return "attachment";
|
|
850
|
+
if (!question.options?.length) return "text";
|
|
851
|
+
return legacy.multiSelect ? "multi_select" : "single_select";
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
interface AskAnswer {
|
|
855
|
+
selections: string[];
|
|
856
|
+
text: string;
|
|
857
|
+
attachments: unknown[];
|
|
840
858
|
}
|
|
841
859
|
|
|
842
860
|
export function AskUserPart({
|
|
@@ -845,9 +863,9 @@ export function AskUserPart({
|
|
|
845
863
|
onSubmit,
|
|
846
864
|
}: {
|
|
847
865
|
questions: readonly AskQuestion[];
|
|
848
|
-
submittedAnswers?: ReadonlyArray<
|
|
866
|
+
submittedAnswers?: ReadonlyArray<AskAnswer>;
|
|
849
867
|
onSubmit?: (payload: {
|
|
850
|
-
answers:
|
|
868
|
+
answers: AskAnswer[];
|
|
851
869
|
}) => void | Promise<void>;
|
|
852
870
|
}) {
|
|
853
871
|
const [answers, setAnswers] = useState<Array<{
|
|
@@ -859,8 +877,17 @@ export function AskUserPart({
|
|
|
859
877
|
const [inFlight, setInFlight] = useState(false);
|
|
860
878
|
const resolvedSubmitted = submittedAnswers.length > 0;
|
|
861
879
|
const valid = questions.length > 0 && questions.every(
|
|
862
|
-
(
|
|
863
|
-
|
|
880
|
+
(question, index) => {
|
|
881
|
+
const responseType = askQuestionResponseType(question);
|
|
882
|
+
const answer = answerAt(index);
|
|
883
|
+
if (
|
|
884
|
+
answer.selections.length === 0 && !answer.text.trim()
|
|
885
|
+
) return question.required !== true;
|
|
886
|
+
if (responseType === "attachment") return false;
|
|
887
|
+
if (responseType === "text") return Boolean(answer.text.trim());
|
|
888
|
+
return answer.selections.length > 0 ||
|
|
889
|
+
(question.allowCustom === true && Boolean(answer.text.trim()));
|
|
890
|
+
},
|
|
864
891
|
);
|
|
865
892
|
const toggle = (questionIndex: number, option: string) => {
|
|
866
893
|
if (resolvedSubmitted || inFlight || !onSubmit) return;
|
|
@@ -871,7 +898,8 @@ export function AskUserPart({
|
|
|
871
898
|
const answer = next[questionIndex]!;
|
|
872
899
|
next[questionIndex] = {
|
|
873
900
|
...answer,
|
|
874
|
-
selections: questions[questionIndex]
|
|
901
|
+
selections: askQuestionResponseType(questions[questionIndex]!) ===
|
|
902
|
+
"multi_select"
|
|
875
903
|
? answer.selections.includes(option)
|
|
876
904
|
? answer.selections.filter((item) => item !== option)
|
|
877
905
|
: [...answer.selections, option]
|
|
@@ -888,6 +916,7 @@ export function AskUserPart({
|
|
|
888
916
|
answers: questions.map((_, index) => ({
|
|
889
917
|
selections: answerAt(index).selections,
|
|
890
918
|
text: answerAt(index).text.trim(),
|
|
919
|
+
attachments: [],
|
|
891
920
|
})),
|
|
892
921
|
});
|
|
893
922
|
} catch {
|
|
@@ -900,63 +929,77 @@ export function AskUserPart({
|
|
|
900
929
|
data-part-type="ask_user"
|
|
901
930
|
data-state={resolvedSubmitted ? "submitted" : "pending"}
|
|
902
931
|
>
|
|
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
|
-
|
|
932
|
+
{questions.map((question, questionIndex) => {
|
|
933
|
+
const responseType = askQuestionResponseType(question);
|
|
934
|
+
const choice = responseType === "single_select" ||
|
|
935
|
+
responseType === "multi_select";
|
|
936
|
+
return (
|
|
937
|
+
<fieldset
|
|
938
|
+
key={`${questionIndex}:${question.question}`}
|
|
939
|
+
disabled={resolvedSubmitted || inFlight || !onSubmit}
|
|
940
|
+
>
|
|
941
|
+
<legend>
|
|
942
|
+
{question.question}
|
|
943
|
+
{question.required !== true && <span> Optional</span>}
|
|
944
|
+
</legend>
|
|
945
|
+
<div>
|
|
946
|
+
{choice && (question.options ?? []).map((option) => {
|
|
947
|
+
const selected = submittedAnswers[questionIndex]
|
|
948
|
+
? submittedAnswers[questionIndex]!.selections.includes(option)
|
|
949
|
+
: answerAt(questionIndex).selections.includes(option);
|
|
950
|
+
return (
|
|
951
|
+
<button
|
|
952
|
+
key={option}
|
|
953
|
+
type="button"
|
|
954
|
+
aria-pressed={selected}
|
|
955
|
+
onClick={() => toggle(questionIndex, option)}
|
|
956
|
+
>
|
|
957
|
+
{responseType === "multi_select" &&
|
|
958
|
+
<span>{selected && <CheckIcon size={12} />}</span>}
|
|
959
|
+
{selected && responseType === "single_select" &&
|
|
960
|
+
<CheckIcon size={14} />}
|
|
961
|
+
{option}
|
|
962
|
+
</button>
|
|
963
|
+
);
|
|
964
|
+
})}
|
|
965
|
+
</div>
|
|
966
|
+
{!resolvedSubmitted &&
|
|
967
|
+
(responseType === "text" || question.allowCustom === true) && (
|
|
968
|
+
<textarea
|
|
969
|
+
value={answerAt(questionIndex).text}
|
|
970
|
+
maxLength={2_000}
|
|
971
|
+
aria-label={`Custom answer: ${question.question}`}
|
|
972
|
+
onChange={(event) =>
|
|
973
|
+
setAnswers((current) => {
|
|
974
|
+
const next = questions.map((_, index) =>
|
|
975
|
+
current[index] ?? { selections: [], text: "" }
|
|
976
|
+
);
|
|
977
|
+
next[questionIndex] = {
|
|
978
|
+
...next[questionIndex]!,
|
|
979
|
+
text: event.currentTarget.value,
|
|
980
|
+
};
|
|
981
|
+
return next;
|
|
982
|
+
})}
|
|
983
|
+
placeholder={question.options?.length
|
|
984
|
+
? "Other"
|
|
985
|
+
: "Type your answer"}
|
|
986
|
+
/>
|
|
987
|
+
)}
|
|
988
|
+
{submittedAnswers[questionIndex] && (
|
|
989
|
+
<small>
|
|
990
|
+
{[
|
|
991
|
+
...submittedAnswers[questionIndex]!.selections,
|
|
992
|
+
...(submittedAnswers[questionIndex]!.text?.trim()
|
|
993
|
+
? [submittedAnswers[questionIndex]!.text!.trim()]
|
|
994
|
+
: []),
|
|
995
|
+
].join(" / ")}
|
|
996
|
+
</small>
|
|
947
997
|
)}
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
? [submittedAnswers[questionIndex]!.text!.trim()]
|
|
954
|
-
: []),
|
|
955
|
-
].join(" / ")}
|
|
956
|
-
</small>
|
|
957
|
-
)}
|
|
958
|
-
</fieldset>
|
|
959
|
-
))}
|
|
998
|
+
{responseType === "attachment" && !resolvedSubmitted &&
|
|
999
|
+
<small>File upload is unavailable.</small>}
|
|
1000
|
+
</fieldset>
|
|
1001
|
+
);
|
|
1002
|
+
})}
|
|
960
1003
|
{resolvedSubmitted ? (
|
|
961
1004
|
<div className="sb-ask-user__submitted">
|
|
962
1005
|
<CheckIcon size={15} />
|
|
@@ -1170,6 +1213,9 @@ export function DefaultPartRenderer({
|
|
|
1170
1213
|
return {
|
|
1171
1214
|
selections: strings(value.selections),
|
|
1172
1215
|
text: typeof value.text === "string" ? value.text : "",
|
|
1216
|
+
attachments: Array.isArray(value.attachments)
|
|
1217
|
+
? value.attachments
|
|
1218
|
+
: [],
|
|
1173
1219
|
};
|
|
1174
1220
|
})
|
|
1175
1221
|
: [];
|