@springbrand/message-panel 0.2.0-alpha.48 → 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 +64 -46
- package/cloud-os/chat/tool-presentation.ts +52 -52
- package/cloud-os/file-view.tsx +2 -2
- 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,6 +21,7 @@ import {
|
|
|
21
21
|
CollapsibleTrigger,
|
|
22
22
|
} from "../primitives/collapsible";
|
|
23
23
|
import { Tooltip } from "../primitives/tooltip";
|
|
24
|
+
import { fileMeta, ImagePreview } from "../file-view";
|
|
24
25
|
import { WorkshopButton } from "../primitives/workshop-controls";
|
|
25
26
|
import { MarkdownMessage } from "./markdown-message";
|
|
26
27
|
import { collapsePanel } from "./surfaces";
|
|
@@ -144,9 +145,8 @@ export function PlanBlock({
|
|
|
144
145
|
interface AskQuestion {
|
|
145
146
|
prompt: string;
|
|
146
147
|
options: string[];
|
|
147
|
-
multi: boolean;
|
|
148
148
|
allowCustom: boolean;
|
|
149
|
-
|
|
149
|
+
responseType: "text" | "single_select" | "multi_select" | "attachment";
|
|
150
150
|
}
|
|
151
151
|
|
|
152
152
|
function questionsOf(input: Record<string, unknown>): AskQuestion[] {
|
|
@@ -158,14 +158,25 @@ function questionsOf(input: Record<string, unknown>): AskQuestion[] {
|
|
|
158
158
|
typeof option === "string"
|
|
159
159
|
)
|
|
160
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";
|
|
161
173
|
return {
|
|
162
174
|
prompt: typeof question.question === "string"
|
|
163
175
|
? question.question
|
|
164
176
|
: "Choose an option",
|
|
165
177
|
options,
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
kind: question.kind === "attachment" ? "attachment" : "ordinary",
|
|
178
|
+
allowCustom: responseType === "text" || question.allowCustom === true,
|
|
179
|
+
responseType,
|
|
169
180
|
};
|
|
170
181
|
});
|
|
171
182
|
}
|
|
@@ -198,13 +209,6 @@ export interface AskUserBlockProps {
|
|
|
198
209
|
attachmentField?: AskUserAttachmentController;
|
|
199
210
|
}
|
|
200
211
|
|
|
201
|
-
function formatAttachmentSize(size: number | undefined): string {
|
|
202
|
-
if (size === undefined) return "Ready";
|
|
203
|
-
if (size < 1_024) return `${size} B`;
|
|
204
|
-
if (size < 1_024 * 1_024) return `${Math.round(size / 1_024)} KB`;
|
|
205
|
-
return `${(size / 1_024 / 1_024).toFixed(1)} MB`;
|
|
206
|
-
}
|
|
207
|
-
|
|
208
212
|
function AttachmentImage({ src }: { src: string }) {
|
|
209
213
|
const [failed, setFailed] = useState(false);
|
|
210
214
|
useEffect(() => setFailed(false), [src]);
|
|
@@ -223,8 +227,6 @@ function AttachmentImage({ src }: { src: string }) {
|
|
|
223
227
|
export function AskUserAttachmentField({
|
|
224
228
|
attachments,
|
|
225
229
|
disabled,
|
|
226
|
-
busy,
|
|
227
|
-
hasFailure,
|
|
228
230
|
onFilesSelected,
|
|
229
231
|
onRemove,
|
|
230
232
|
onRetry,
|
|
@@ -271,7 +273,7 @@ export function AskUserAttachmentField({
|
|
|
271
273
|
addFiles(files);
|
|
272
274
|
}
|
|
273
275
|
}}
|
|
274
|
-
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"
|
|
275
277
|
>
|
|
276
278
|
<UploadSimple size={18} aria-hidden="true" />
|
|
277
279
|
<span>Drop files here or choose files</span>
|
|
@@ -286,7 +288,7 @@ export function AskUserAttachmentField({
|
|
|
286
288
|
? `Uploading ${Math.round((attachment.progress ?? 0) * 100)}%`
|
|
287
289
|
: attachment.status === "error"
|
|
288
290
|
? "Could not upload this file. Try again."
|
|
289
|
-
:
|
|
291
|
+
: fileMeta({ size: attachment.size });
|
|
290
292
|
const preview = (
|
|
291
293
|
<span
|
|
292
294
|
className="grid size-10 shrink-0 place-items-center overflow-hidden rounded-lg bg-kumo-control text-kumo-inactive themed-thumbnail-shadow"
|
|
@@ -305,17 +307,27 @@ export function AskUserAttachmentField({
|
|
|
305
307
|
className="flex min-w-0 items-center gap-3 rounded-xl border border-kumo-line bg-kumo-elevated/55 p-2"
|
|
306
308
|
>
|
|
307
309
|
{attachment.status === "ready" && attachment.previewUrl
|
|
308
|
-
?
|
|
310
|
+
? image
|
|
311
|
+
? (
|
|
312
|
+
<ImagePreview
|
|
313
|
+
src={attachment.previewUrl}
|
|
314
|
+
label={label}
|
|
315
|
+
placement="assistant-message"
|
|
316
|
+
>
|
|
317
|
+
{preview}
|
|
318
|
+
</ImagePreview>
|
|
319
|
+
)
|
|
320
|
+
: (
|
|
309
321
|
<a
|
|
310
322
|
href={attachment.previewUrl}
|
|
311
323
|
target="_blank"
|
|
312
324
|
rel="noopener noreferrer"
|
|
313
|
-
aria-label={
|
|
325
|
+
aria-label={`Open ${label}`}
|
|
314
326
|
className="rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/40"
|
|
315
327
|
>
|
|
316
328
|
{preview}
|
|
317
329
|
</a>
|
|
318
|
-
|
|
330
|
+
)
|
|
319
331
|
: preview}
|
|
320
332
|
<span className="flex min-w-0 flex-1 flex-col">
|
|
321
333
|
<span className="truncate text-[13px] font-medium text-kumo-default">
|
|
@@ -354,11 +366,6 @@ export function AskUserAttachmentField({
|
|
|
354
366
|
})}
|
|
355
367
|
</div>
|
|
356
368
|
)}
|
|
357
|
-
{(busy || hasFailure) && attachments.length === 0 && (
|
|
358
|
-
<span role={hasFailure ? "alert" : "status"} className="text-[11px] text-kumo-inactive">
|
|
359
|
-
{hasFailure ? "Could not upload this file. Try again." : "Uploading"}
|
|
360
|
-
</span>
|
|
361
|
-
)}
|
|
362
369
|
</div>
|
|
363
370
|
);
|
|
364
371
|
}
|
|
@@ -375,7 +382,7 @@ export function AskUserBlock({
|
|
|
375
382
|
const [answers, setAnswers] = useState<DraftAnswer[]>([]);
|
|
376
383
|
const answerAt = (index: number): DraftAnswer =>
|
|
377
384
|
answers[index] ?? { selections: [], text: "" };
|
|
378
|
-
const toggle = (questionIndex: number, option: string
|
|
385
|
+
const toggle = (questionIndex: number, option: string) =>
|
|
379
386
|
setAnswers((current) => {
|
|
380
387
|
const next = questions.map((_, index) =>
|
|
381
388
|
current[index] ?? { selections: [], text: "" }
|
|
@@ -383,11 +390,11 @@ export function AskUserBlock({
|
|
|
383
390
|
const answer = next[questionIndex]!;
|
|
384
391
|
next[questionIndex] = {
|
|
385
392
|
...answer,
|
|
386
|
-
selections:
|
|
387
|
-
?
|
|
388
|
-
: answer.selections.includes(option)
|
|
393
|
+
selections: questions[questionIndex]?.responseType === "multi_select"
|
|
394
|
+
? answer.selections.includes(option)
|
|
389
395
|
? answer.selections.filter((value) => value !== option)
|
|
390
|
-
: [...answer.selections, option]
|
|
396
|
+
: [...answer.selections, option]
|
|
397
|
+
: [option],
|
|
391
398
|
};
|
|
392
399
|
return next;
|
|
393
400
|
});
|
|
@@ -405,7 +412,7 @@ export function AskUserBlock({
|
|
|
405
412
|
const outcome = askUserOutcome(call);
|
|
406
413
|
const responseTimerRef = useRef<number | null>(null);
|
|
407
414
|
const hasAttachmentQuestion = questions.some(
|
|
408
|
-
(question) => question.
|
|
415
|
+
(question) => question.responseType === "attachment",
|
|
409
416
|
);
|
|
410
417
|
const clearResponseTimer = () => {
|
|
411
418
|
if (responseTimerRef.current === null) return;
|
|
@@ -419,7 +426,7 @@ export function AskUserBlock({
|
|
|
419
426
|
const displayAnswerAt = (index: number): DraftAnswer => {
|
|
420
427
|
if (outcome.kind !== "answered") return answerAt(index);
|
|
421
428
|
const answer = outcome.answers[index];
|
|
422
|
-
if (!answer
|
|
429
|
+
if (!answer) {
|
|
423
430
|
return { selections: [], text: "" };
|
|
424
431
|
}
|
|
425
432
|
return {
|
|
@@ -430,14 +437,17 @@ export function AskUserBlock({
|
|
|
430
437
|
// 服务端已经结算 = 这张卡永久不可操作,不管结算成什么。
|
|
431
438
|
const resolved = outcome.kind !== "pending" || inFlight;
|
|
432
439
|
const valid = questions.length > 0 && questions.every(
|
|
433
|
-
(question, index) => question.
|
|
440
|
+
(question, index) => question.responseType === "attachment"
|
|
434
441
|
? Boolean(
|
|
435
442
|
attachmentField &&
|
|
436
443
|
attachmentField.attachments.length > 0 &&
|
|
437
444
|
!attachmentField.busy &&
|
|
438
445
|
!attachmentField.hasFailure
|
|
439
446
|
)
|
|
440
|
-
:
|
|
447
|
+
: question.responseType === "text"
|
|
448
|
+
? Boolean(answerAt(index).text.trim())
|
|
449
|
+
: answerAt(index).selections.length > 0 ||
|
|
450
|
+
(question.allowCustom && Boolean(answerAt(index).text.trim())),
|
|
441
451
|
);
|
|
442
452
|
const buttonLabel = inFlight ? "Submitting…" : "Submit";
|
|
443
453
|
const footnote = outcome.kind === "closed"
|
|
@@ -452,12 +462,17 @@ export function AskUserBlock({
|
|
|
452
462
|
try {
|
|
453
463
|
const response = {
|
|
454
464
|
answers: questions.map((question, index) =>
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
+
})
|
|
461
476
|
),
|
|
462
477
|
};
|
|
463
478
|
setInFlight(true);
|
|
@@ -501,6 +516,8 @@ export function AskUserBlock({
|
|
|
501
516
|
const options = outcome.kind === "answered"
|
|
502
517
|
? [...new Set([...question.options, ...answer.selections])]
|
|
503
518
|
: question.options;
|
|
519
|
+
const choice = question.responseType === "single_select" ||
|
|
520
|
+
question.responseType === "multi_select";
|
|
504
521
|
return (
|
|
505
522
|
<div
|
|
506
523
|
key={`${question.prompt}:${questionIndex}`}
|
|
@@ -509,10 +526,10 @@ export function AskUserBlock({
|
|
|
509
526
|
<p className="m-0 text-[14px] leading-[1.4] font-normal text-kumo-default">
|
|
510
527
|
{question.prompt}
|
|
511
528
|
</p>
|
|
512
|
-
{question.
|
|
529
|
+
{question.responseType === "attachment" && (
|
|
513
530
|
<AskUserAttachmentField
|
|
514
531
|
attachments={
|
|
515
|
-
settledAttachmentAnswer
|
|
532
|
+
settledAttachmentAnswer
|
|
516
533
|
? settledAttachmentAnswer.attachments.map((attachment) => ({
|
|
517
534
|
id: attachment.attachmentId,
|
|
518
535
|
filename: attachment.name,
|
|
@@ -531,7 +548,7 @@ export function AskUserBlock({
|
|
|
531
548
|
onRetry={attachmentField?.onRetry ?? (() => undefined)}
|
|
532
549
|
/>
|
|
533
550
|
)}
|
|
534
|
-
{
|
|
551
|
+
{choice && options.length > 0 && (
|
|
535
552
|
<div className="flex flex-wrap items-center gap-2">
|
|
536
553
|
{options.map((option) => {
|
|
537
554
|
const selected = answer.selections.includes(option);
|
|
@@ -541,8 +558,7 @@ export function AskUserBlock({
|
|
|
541
558
|
type="button"
|
|
542
559
|
aria-pressed={selected}
|
|
543
560
|
disabled={resolved || !onRespond}
|
|
544
|
-
onClick={() =>
|
|
545
|
-
toggle(questionIndex, option, question.multi)}
|
|
561
|
+
onClick={() => toggle(questionIndex, option)}
|
|
546
562
|
style={selected
|
|
547
563
|
? {
|
|
548
564
|
background:
|
|
@@ -563,7 +579,8 @@ export function AskUserBlock({
|
|
|
563
579
|
})}
|
|
564
580
|
</div>
|
|
565
581
|
)}
|
|
566
|
-
{question.
|
|
582
|
+
{(question.responseType === "text" ||
|
|
583
|
+
(choice && question.allowCustom)) && (
|
|
567
584
|
<label className="flex w-full flex-col gap-2 text-[14px] leading-[1.4] text-kumo-default">
|
|
568
585
|
<span>{question.options.length > 0 ? "Other" : "Answer"}</span>
|
|
569
586
|
<input
|
|
@@ -581,7 +598,8 @@ export function AskUserBlock({
|
|
|
581
598
|
/>
|
|
582
599
|
</label>
|
|
583
600
|
)}
|
|
584
|
-
{question.
|
|
601
|
+
{question.responseType !== "attachment" &&
|
|
602
|
+
outcome.kind === "answered" &&
|
|
585
603
|
!question.allowCustom && answer.text.trim() && (
|
|
586
604
|
<p className="m-0 text-[14px] leading-[1.4] text-kumo-subtle">
|
|
587
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`;
|
|
@@ -270,7 +270,7 @@ function ProductFileView({
|
|
|
270
270
|
);
|
|
271
271
|
}
|
|
272
272
|
|
|
273
|
-
function ImagePreview({
|
|
273
|
+
export function ImagePreview({
|
|
274
274
|
src,
|
|
275
275
|
label,
|
|
276
276
|
placement,
|
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
|
: [];
|