@springbrand/message-panel 0.2.0-alpha.46 → 0.2.0-alpha.48
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.
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
import {
|
|
33
33
|
ApprovalBlock,
|
|
34
34
|
AskUserBlock,
|
|
35
|
+
type AskUserBlockProps,
|
|
35
36
|
ErrorBlock,
|
|
36
37
|
ParallelBlock,
|
|
37
38
|
PlanBlock,
|
|
@@ -96,6 +97,8 @@ export interface CloudOsChatMessagesProps {
|
|
|
96
97
|
input: Record<string, unknown>;
|
|
97
98
|
disabled: boolean;
|
|
98
99
|
}) => ReactNode;
|
|
100
|
+
/** Lets the Host add its upload lifecycle while retaining AskUserBlock presentation. */
|
|
101
|
+
renderAskUser?: (props: AskUserBlockProps) => ReactNode;
|
|
99
102
|
/** 接管消息里的文件视图;未提供时使用包内 FileView。 */
|
|
100
103
|
renderFile?: FileRenderer;
|
|
101
104
|
/** 把消息里的 `sandbox:/workspace/...` 之类地址翻译成可访问 URL。 */
|
|
@@ -382,6 +385,7 @@ export function CloudOsChatMessages({
|
|
|
382
385
|
approvalIdOf,
|
|
383
386
|
approvalsDisabled = false,
|
|
384
387
|
renderApproval,
|
|
388
|
+
renderAskUser,
|
|
385
389
|
renderFile,
|
|
386
390
|
resolveUrl,
|
|
387
391
|
resolveArtifactId,
|
|
@@ -822,11 +826,17 @@ export function CloudOsChatMessages({
|
|
|
822
826
|
);
|
|
823
827
|
case "askUser":
|
|
824
828
|
return (
|
|
825
|
-
<
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
829
|
+
<Fragment key={block.key}>
|
|
830
|
+
{renderAskUser?.({
|
|
831
|
+
call: block.call,
|
|
832
|
+
onRespond,
|
|
833
|
+
}) ?? (
|
|
834
|
+
<AskUserBlock
|
|
835
|
+
call={block.call}
|
|
836
|
+
onRespond={onRespond}
|
|
837
|
+
/>
|
|
838
|
+
)}
|
|
839
|
+
</Fragment>
|
|
830
840
|
);
|
|
831
841
|
case "suggestions":
|
|
832
842
|
return null;
|
|
@@ -7,10 +7,14 @@ import {
|
|
|
7
7
|
Clock,
|
|
8
8
|
Key,
|
|
9
9
|
ListChecks,
|
|
10
|
+
File as FileIcon,
|
|
11
|
+
Image as ImageIcon,
|
|
12
|
+
UploadSimple,
|
|
10
13
|
UsersThree,
|
|
11
14
|
WarningCircle,
|
|
12
15
|
} from "@phosphor-icons/react";
|
|
13
|
-
import { useState } from "react";
|
|
16
|
+
import { useEffect, useRef, useState } from "react";
|
|
17
|
+
import type { FileUIPart } from "ai";
|
|
14
18
|
import {
|
|
15
19
|
Collapsible,
|
|
16
20
|
CollapsibleContent,
|
|
@@ -30,6 +34,7 @@ import type {
|
|
|
30
34
|
PlanStep,
|
|
31
35
|
SubAgentView,
|
|
32
36
|
} from "./transcript-model";
|
|
37
|
+
import type { CloudOsAttachmentView } from "../composer/cloud-os-chat-input";
|
|
33
38
|
import {
|
|
34
39
|
askUserOutcome,
|
|
35
40
|
humanizeToolName,
|
|
@@ -141,6 +146,7 @@ interface AskQuestion {
|
|
|
141
146
|
options: string[];
|
|
142
147
|
multi: boolean;
|
|
143
148
|
allowCustom: boolean;
|
|
149
|
+
kind: "ordinary" | "attachment";
|
|
144
150
|
}
|
|
145
151
|
|
|
146
152
|
function questionsOf(input: Record<string, unknown>): AskQuestion[] {
|
|
@@ -159,6 +165,7 @@ function questionsOf(input: Record<string, unknown>): AskQuestion[] {
|
|
|
159
165
|
options,
|
|
160
166
|
multi: question.multiSelect === true,
|
|
161
167
|
allowCustom: question.allowCustom === true || options.length === 0,
|
|
168
|
+
kind: question.kind === "attachment" ? "attachment" : "ordinary",
|
|
162
169
|
};
|
|
163
170
|
});
|
|
164
171
|
}
|
|
@@ -168,14 +175,199 @@ interface DraftAnswer {
|
|
|
168
175
|
text: string;
|
|
169
176
|
}
|
|
170
177
|
|
|
178
|
+
export interface AskUserAttachmentFieldProps {
|
|
179
|
+
attachments: readonly CloudOsAttachmentView[];
|
|
180
|
+
disabled: boolean;
|
|
181
|
+
busy: boolean;
|
|
182
|
+
hasFailure: boolean;
|
|
183
|
+
onFilesSelected: (files: readonly File[]) => void;
|
|
184
|
+
onRemove: (id: string) => void;
|
|
185
|
+
onRetry: (id: string) => void;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface AskUserAttachmentController
|
|
189
|
+
extends Omit<AskUserAttachmentFieldProps, "disabled"> {
|
|
190
|
+
getParts: () => readonly FileUIPart[];
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export interface AskUserBlockProps {
|
|
194
|
+
call: CloudOsToolCall;
|
|
195
|
+
/** 缺省(宿主没接)时选项不可点。 */
|
|
196
|
+
onRespond?: (toolCallId: string, response: unknown) => Promise<boolean>;
|
|
197
|
+
/** Host-owned upload lifecycle for the single Attachment question. */
|
|
198
|
+
attachmentField?: AskUserAttachmentController;
|
|
199
|
+
}
|
|
200
|
+
|
|
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
|
+
function AttachmentImage({ src }: { src: string }) {
|
|
209
|
+
const [failed, setFailed] = useState(false);
|
|
210
|
+
useEffect(() => setFailed(false), [src]);
|
|
211
|
+
return failed
|
|
212
|
+
? <ImageIcon size={18} aria-hidden="true" />
|
|
213
|
+
: (
|
|
214
|
+
<img
|
|
215
|
+
src={src}
|
|
216
|
+
alt=""
|
|
217
|
+
className="size-10 object-cover"
|
|
218
|
+
onError={() => setFailed(true)}
|
|
219
|
+
/>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function AskUserAttachmentField({
|
|
224
|
+
attachments,
|
|
225
|
+
disabled,
|
|
226
|
+
busy,
|
|
227
|
+
hasFailure,
|
|
228
|
+
onFilesSelected,
|
|
229
|
+
onRemove,
|
|
230
|
+
onRetry,
|
|
231
|
+
}: AskUserAttachmentFieldProps) {
|
|
232
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
233
|
+
const addFiles = (files: FileList | readonly File[]) => {
|
|
234
|
+
if (!disabled && files.length > 0) onFilesSelected(Array.from(files));
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
return (
|
|
238
|
+
<div className="flex w-full flex-col gap-2.5">
|
|
239
|
+
<>
|
|
240
|
+
<input
|
|
241
|
+
ref={inputRef}
|
|
242
|
+
type="file"
|
|
243
|
+
multiple
|
|
244
|
+
disabled={disabled}
|
|
245
|
+
className="sr-only"
|
|
246
|
+
aria-label="Choose files"
|
|
247
|
+
onChange={(event) => {
|
|
248
|
+
if (event.currentTarget.files) addFiles(event.currentTarget.files);
|
|
249
|
+
event.currentTarget.value = "";
|
|
250
|
+
}}
|
|
251
|
+
/>
|
|
252
|
+
<button
|
|
253
|
+
type="button"
|
|
254
|
+
aria-label="Choose files"
|
|
255
|
+
disabled={disabled}
|
|
256
|
+
onClick={() => inputRef.current?.click()}
|
|
257
|
+
onDragOver={(event) => {
|
|
258
|
+
event.preventDefault();
|
|
259
|
+
}}
|
|
260
|
+
onDrop={(event) => {
|
|
261
|
+
event.preventDefault();
|
|
262
|
+
addFiles(event.dataTransfer.files);
|
|
263
|
+
}}
|
|
264
|
+
onPaste={(event) => {
|
|
265
|
+
const files = Array.from(event.clipboardData.items)
|
|
266
|
+
.filter((item) => item.kind === "file")
|
|
267
|
+
.map((item) => item.getAsFile())
|
|
268
|
+
.filter((file): file is File => file !== null);
|
|
269
|
+
if (files.length > 0) {
|
|
270
|
+
event.preventDefault();
|
|
271
|
+
addFiles(files);
|
|
272
|
+
}
|
|
273
|
+
}}
|
|
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:border-kumo-brand 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
|
+
>
|
|
276
|
+
<UploadSimple size={18} aria-hidden="true" />
|
|
277
|
+
<span>Drop files here or choose files</span>
|
|
278
|
+
</button>
|
|
279
|
+
</>
|
|
280
|
+
{attachments.length > 0 && (
|
|
281
|
+
<div className="flex flex-col gap-2">
|
|
282
|
+
{attachments.map((attachment) => {
|
|
283
|
+
const image = attachment.mediaType?.startsWith("image/") === true;
|
|
284
|
+
const label = attachment.filename ?? "Attachment";
|
|
285
|
+
const status = attachment.status === "uploading"
|
|
286
|
+
? `Uploading ${Math.round((attachment.progress ?? 0) * 100)}%`
|
|
287
|
+
: attachment.status === "error"
|
|
288
|
+
? "Could not upload this file. Try again."
|
|
289
|
+
: formatAttachmentSize(attachment.size);
|
|
290
|
+
const preview = (
|
|
291
|
+
<span
|
|
292
|
+
className="grid size-10 shrink-0 place-items-center overflow-hidden rounded-lg bg-kumo-control text-kumo-inactive themed-thumbnail-shadow"
|
|
293
|
+
data-attachment-kind={image ? "image" : "file"}
|
|
294
|
+
>
|
|
295
|
+
{image && attachment.previewUrl
|
|
296
|
+
? <AttachmentImage src={attachment.previewUrl} />
|
|
297
|
+
: image
|
|
298
|
+
? <ImageIcon size={18} aria-hidden="true" />
|
|
299
|
+
: <FileIcon size={18} aria-hidden="true" />}
|
|
300
|
+
</span>
|
|
301
|
+
);
|
|
302
|
+
return (
|
|
303
|
+
<div
|
|
304
|
+
key={attachment.id}
|
|
305
|
+
className="flex min-w-0 items-center gap-3 rounded-xl border border-kumo-line bg-kumo-elevated/55 p-2"
|
|
306
|
+
>
|
|
307
|
+
{attachment.status === "ready" && attachment.previewUrl
|
|
308
|
+
? (
|
|
309
|
+
<a
|
|
310
|
+
href={attachment.previewUrl}
|
|
311
|
+
target="_blank"
|
|
312
|
+
rel="noopener noreferrer"
|
|
313
|
+
aria-label={`${image ? "Preview" : "Open"} ${label}`}
|
|
314
|
+
className="rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/40"
|
|
315
|
+
>
|
|
316
|
+
{preview}
|
|
317
|
+
</a>
|
|
318
|
+
)
|
|
319
|
+
: preview}
|
|
320
|
+
<span className="flex min-w-0 flex-1 flex-col">
|
|
321
|
+
<span className="truncate text-[13px] font-medium text-kumo-default">
|
|
322
|
+
{label}
|
|
323
|
+
</span>
|
|
324
|
+
<span
|
|
325
|
+
className={attachment.status === "error"
|
|
326
|
+
? "text-[11px] text-kumo-danger"
|
|
327
|
+
: "text-[11px] text-kumo-inactive"}
|
|
328
|
+
role={attachment.status === "error" ? "alert" : "status"}
|
|
329
|
+
>
|
|
330
|
+
{status}
|
|
331
|
+
</span>
|
|
332
|
+
</span>
|
|
333
|
+
{attachment.status === "error" && (
|
|
334
|
+
<button
|
|
335
|
+
type="button"
|
|
336
|
+
disabled={disabled}
|
|
337
|
+
onClick={() => onRetry(attachment.id)}
|
|
338
|
+
className="rounded-lg px-2 py-1 text-[12px] text-kumo-danger hover:bg-kumo-tint focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/40 disabled:opacity-50"
|
|
339
|
+
>
|
|
340
|
+
Retry
|
|
341
|
+
</button>
|
|
342
|
+
)}
|
|
343
|
+
<button
|
|
344
|
+
type="button"
|
|
345
|
+
disabled={disabled}
|
|
346
|
+
aria-label={`Remove ${label}`}
|
|
347
|
+
onClick={() => onRemove(attachment.id)}
|
|
348
|
+
className="rounded-lg px-2 py-1 text-[12px] text-kumo-inactive hover:bg-kumo-tint hover:text-kumo-default focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/40 disabled:opacity-50"
|
|
349
|
+
>
|
|
350
|
+
Remove
|
|
351
|
+
</button>
|
|
352
|
+
</div>
|
|
353
|
+
);
|
|
354
|
+
})}
|
|
355
|
+
</div>
|
|
356
|
+
)}
|
|
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
|
+
</div>
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
171
366
|
export function AskUserBlock({
|
|
172
367
|
call,
|
|
173
368
|
onRespond,
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
/** 缺省(宿主没接)时选项不可点 —— 形状对齐 ScheduleBlock 的 onOpen。 */
|
|
177
|
-
onRespond?: (toolCallId: string, response: unknown) => Promise<boolean>;
|
|
178
|
-
}) {
|
|
369
|
+
attachmentField,
|
|
370
|
+
}: AskUserBlockProps) {
|
|
179
371
|
const questions = questionsOf(call.input);
|
|
180
372
|
// 不能用 `useState(() => questions.map(...))` 初始化:工具入参是**流式**到达的,
|
|
181
373
|
// 首帧 questions 往往还是空数组,那之后每次 setAnswers 都在空数组上 map,
|
|
@@ -209,10 +401,27 @@ export function AskUserBlock({
|
|
|
209
401
|
});
|
|
210
402
|
// 在途只用于按钮转圈;权威状态永远是 part 的 state。
|
|
211
403
|
const [inFlight, setInFlight] = useState(false);
|
|
404
|
+
const [submissionError, setSubmissionError] = useState(false);
|
|
212
405
|
const outcome = askUserOutcome(call);
|
|
406
|
+
const responseTimerRef = useRef<number | null>(null);
|
|
407
|
+
const hasAttachmentQuestion = questions.some(
|
|
408
|
+
(question) => question.kind === "attachment",
|
|
409
|
+
);
|
|
410
|
+
const clearResponseTimer = () => {
|
|
411
|
+
if (responseTimerRef.current === null) return;
|
|
412
|
+
window.clearTimeout(responseTimerRef.current);
|
|
413
|
+
responseTimerRef.current = null;
|
|
414
|
+
};
|
|
415
|
+
useEffect(() => () => clearResponseTimer(), []);
|
|
416
|
+
useEffect(() => {
|
|
417
|
+
if (outcome.kind !== "pending") clearResponseTimer();
|
|
418
|
+
}, [outcome.kind]);
|
|
213
419
|
const displayAnswerAt = (index: number): DraftAnswer => {
|
|
214
420
|
if (outcome.kind !== "answered") return answerAt(index);
|
|
215
421
|
const answer = outcome.answers[index];
|
|
422
|
+
if (!answer || !("selections" in answer)) {
|
|
423
|
+
return { selections: [], text: "" };
|
|
424
|
+
}
|
|
216
425
|
return {
|
|
217
426
|
selections: answer?.selections ?? [],
|
|
218
427
|
text: answer?.text ?? "",
|
|
@@ -221,8 +430,14 @@ export function AskUserBlock({
|
|
|
221
430
|
// 服务端已经结算 = 这张卡永久不可操作,不管结算成什么。
|
|
222
431
|
const resolved = outcome.kind !== "pending" || inFlight;
|
|
223
432
|
const valid = questions.length > 0 && questions.every(
|
|
224
|
-
(
|
|
225
|
-
|
|
433
|
+
(question, index) => question.kind === "attachment"
|
|
434
|
+
? Boolean(
|
|
435
|
+
attachmentField &&
|
|
436
|
+
attachmentField.attachments.length > 0 &&
|
|
437
|
+
!attachmentField.busy &&
|
|
438
|
+
!attachmentField.hasFailure
|
|
439
|
+
)
|
|
440
|
+
: answerAt(index).selections.length > 0 || Boolean(answerAt(index).text.trim()),
|
|
226
441
|
);
|
|
227
442
|
const buttonLabel = inFlight ? "Submitting…" : "Submit";
|
|
228
443
|
const footnote = outcome.kind === "closed"
|
|
@@ -233,16 +448,42 @@ export function AskUserBlock({
|
|
|
233
448
|
|
|
234
449
|
const submit = () => {
|
|
235
450
|
if (!onRespond) return;
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
451
|
+
setSubmissionError(false);
|
|
452
|
+
try {
|
|
453
|
+
const response = {
|
|
454
|
+
answers: questions.map((question, index) =>
|
|
455
|
+
question.kind === "attachment"
|
|
456
|
+
? { attachments: [...(attachmentField?.getParts() ?? [])] }
|
|
457
|
+
: {
|
|
458
|
+
selections: answerAt(index).selections,
|
|
459
|
+
text: answerAt(index).text.trim(),
|
|
460
|
+
}
|
|
461
|
+
),
|
|
462
|
+
};
|
|
463
|
+
setInFlight(true);
|
|
464
|
+
clearResponseTimer();
|
|
465
|
+
responseTimerRef.current = window.setTimeout(() => {
|
|
466
|
+
responseTimerRef.current = null;
|
|
467
|
+
setInFlight(false);
|
|
468
|
+
setSubmissionError(hasAttachmentQuestion);
|
|
469
|
+
}, 30_000);
|
|
470
|
+
// 失败就把按钮放回可点:权威状态由 part 决定,这里只管别把用户锁死。
|
|
471
|
+
void onRespond(call.toolCallId, response).then((ok) => {
|
|
472
|
+
if (!ok) {
|
|
473
|
+
clearResponseTimer();
|
|
474
|
+
setInFlight(false);
|
|
475
|
+
setSubmissionError(hasAttachmentQuestion);
|
|
476
|
+
}
|
|
477
|
+
}, () => {
|
|
478
|
+
clearResponseTimer();
|
|
479
|
+
setInFlight(false);
|
|
480
|
+
setSubmissionError(hasAttachmentQuestion);
|
|
481
|
+
});
|
|
482
|
+
} catch {
|
|
483
|
+
clearResponseTimer();
|
|
484
|
+
setInFlight(false);
|
|
485
|
+
setSubmissionError(hasAttachmentQuestion);
|
|
486
|
+
}
|
|
246
487
|
};
|
|
247
488
|
|
|
248
489
|
return (
|
|
@@ -254,6 +495,9 @@ export function AskUserBlock({
|
|
|
254
495
|
<div className="flex flex-col gap-4 py-2.5">
|
|
255
496
|
{outcome.kind !== "closed" && questions.map((question, questionIndex) => {
|
|
256
497
|
const answer = displayAnswerAt(questionIndex);
|
|
498
|
+
const settledAttachmentAnswer = outcome.kind === "answered"
|
|
499
|
+
? outcome.answers[questionIndex]
|
|
500
|
+
: undefined;
|
|
257
501
|
const options = outcome.kind === "answered"
|
|
258
502
|
? [...new Set([...question.options, ...answer.selections])]
|
|
259
503
|
: question.options;
|
|
@@ -265,7 +509,29 @@ export function AskUserBlock({
|
|
|
265
509
|
<p className="m-0 text-[14px] leading-[1.4] font-normal text-kumo-default">
|
|
266
510
|
{question.prompt}
|
|
267
511
|
</p>
|
|
268
|
-
{
|
|
512
|
+
{question.kind === "attachment" && (
|
|
513
|
+
<AskUserAttachmentField
|
|
514
|
+
attachments={
|
|
515
|
+
settledAttachmentAnswer && "attachments" in settledAttachmentAnswer
|
|
516
|
+
? settledAttachmentAnswer.attachments.map((attachment) => ({
|
|
517
|
+
id: attachment.attachmentId,
|
|
518
|
+
filename: attachment.name,
|
|
519
|
+
mediaType: attachment.mediaType,
|
|
520
|
+
size: attachment.size,
|
|
521
|
+
previewUrl: attachment.cdnUrl,
|
|
522
|
+
status: "ready" as const,
|
|
523
|
+
}))
|
|
524
|
+
: attachmentField?.attachments ?? []
|
|
525
|
+
}
|
|
526
|
+
disabled={resolved || !onRespond || !attachmentField}
|
|
527
|
+
busy={attachmentField?.busy ?? false}
|
|
528
|
+
hasFailure={attachmentField?.hasFailure ?? false}
|
|
529
|
+
onFilesSelected={attachmentField?.onFilesSelected ?? (() => undefined)}
|
|
530
|
+
onRemove={attachmentField?.onRemove ?? (() => undefined)}
|
|
531
|
+
onRetry={attachmentField?.onRetry ?? (() => undefined)}
|
|
532
|
+
/>
|
|
533
|
+
)}
|
|
534
|
+
{question.kind === "ordinary" && options.length > 0 && (
|
|
269
535
|
<div className="flex flex-wrap items-center gap-2">
|
|
270
536
|
{options.map((option) => {
|
|
271
537
|
const selected = answer.selections.includes(option);
|
|
@@ -297,7 +563,7 @@ export function AskUserBlock({
|
|
|
297
563
|
})}
|
|
298
564
|
</div>
|
|
299
565
|
)}
|
|
300
|
-
{question.allowCustom && (
|
|
566
|
+
{question.kind === "ordinary" && question.allowCustom && (
|
|
301
567
|
<label className="flex w-full flex-col gap-2 text-[14px] leading-[1.4] text-kumo-default">
|
|
302
568
|
<span>{question.options.length > 0 ? "Other" : "Answer"}</span>
|
|
303
569
|
<input
|
|
@@ -315,7 +581,7 @@ export function AskUserBlock({
|
|
|
315
581
|
/>
|
|
316
582
|
</label>
|
|
317
583
|
)}
|
|
318
|
-
{outcome.kind === "answered" &&
|
|
584
|
+
{question.kind === "ordinary" && outcome.kind === "answered" &&
|
|
319
585
|
!question.allowCustom && answer.text.trim() && (
|
|
320
586
|
<p className="m-0 text-[14px] leading-[1.4] text-kumo-subtle">
|
|
321
587
|
{answer.text.trim()}
|
|
@@ -325,6 +591,7 @@ export function AskUserBlock({
|
|
|
325
591
|
);
|
|
326
592
|
})}
|
|
327
593
|
{outcome.kind === "pending" && (
|
|
594
|
+
<>
|
|
328
595
|
<button
|
|
329
596
|
type="button"
|
|
330
597
|
disabled={resolved || !onRespond || !valid}
|
|
@@ -333,6 +600,12 @@ export function AskUserBlock({
|
|
|
333
600
|
>
|
|
334
601
|
{buttonLabel}
|
|
335
602
|
</button>
|
|
603
|
+
{submissionError && (
|
|
604
|
+
<p className="m-0 text-[12px] leading-4 text-kumo-danger" role="alert">
|
|
605
|
+
Could not add these files to the Workspace. Try submitting again.
|
|
606
|
+
</p>
|
|
607
|
+
)}
|
|
608
|
+
</>
|
|
336
609
|
)}
|
|
337
610
|
{outcome.kind === "answered" && (
|
|
338
611
|
<p className="m-0 text-[12px] leading-4 text-kumo-inactive">Submitted</p>
|
|
@@ -503,11 +503,25 @@ export type AskUserOutcome =
|
|
|
503
503
|
| { kind: "pending" }
|
|
504
504
|
| {
|
|
505
505
|
kind: "answered";
|
|
506
|
-
answers: Array<
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
506
|
+
answers: Array<
|
|
507
|
+
| {
|
|
508
|
+
question: string;
|
|
509
|
+
selections: string[];
|
|
510
|
+
text: string;
|
|
511
|
+
}
|
|
512
|
+
| {
|
|
513
|
+
question: string;
|
|
514
|
+
attachments: Array<{
|
|
515
|
+
name: string;
|
|
516
|
+
mediaType: string;
|
|
517
|
+
size: number;
|
|
518
|
+
cdnUrl: string;
|
|
519
|
+
workspacePath: string;
|
|
520
|
+
attachmentId: string;
|
|
521
|
+
contentVersion: string;
|
|
522
|
+
}>;
|
|
523
|
+
}
|
|
524
|
+
>;
|
|
511
525
|
}
|
|
512
526
|
| { kind: "closed"; reason: "user_replied_freeform" | "other" };
|
|
513
527
|
|
|
@@ -518,19 +532,51 @@ export function askUserOutcome(call: CloudOsToolCall): AskUserOutcome {
|
|
|
518
532
|
const record = recordOf(call.output);
|
|
519
533
|
if (Array.isArray(record.answers) && record.answers.length > 0) {
|
|
520
534
|
const answers = record.answers.map(recordOf);
|
|
521
|
-
if (answers.every((answer) =>
|
|
522
|
-
typeof answer.question
|
|
523
|
-
Array.isArray(answer.
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
535
|
+
if (answers.every((answer) => {
|
|
536
|
+
if (typeof answer.question !== "string") return false;
|
|
537
|
+
if (Array.isArray(answer.attachments)) {
|
|
538
|
+
return answer.attachments.length > 0 &&
|
|
539
|
+
answer.attachments.every((value) => {
|
|
540
|
+
const attachment = recordOf(value);
|
|
541
|
+
return typeof attachment.name === "string" &&
|
|
542
|
+
typeof attachment.mediaType === "string" &&
|
|
543
|
+
typeof attachment.size === "number" &&
|
|
544
|
+
typeof attachment.cdnUrl === "string" &&
|
|
545
|
+
typeof attachment.workspacePath === "string" &&
|
|
546
|
+
typeof attachment.attachmentId === "string" &&
|
|
547
|
+
typeof attachment.contentVersion === "string";
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
return Array.isArray(answer.selections) &&
|
|
551
|
+
answer.selections.every((value) => typeof value === "string") &&
|
|
552
|
+
(answer.text === undefined || typeof answer.text === "string");
|
|
553
|
+
})) {
|
|
527
554
|
return {
|
|
528
555
|
kind: "answered",
|
|
529
|
-
answers: answers.map((answer) =>
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
556
|
+
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
|
+
return {
|
|
575
|
+
question: answer.question as string,
|
|
576
|
+
selections: answer.selections as string[],
|
|
577
|
+
text: typeof answer.text === "string" ? answer.text : "",
|
|
578
|
+
};
|
|
579
|
+
}),
|
|
534
580
|
};
|
|
535
581
|
}
|
|
536
582
|
}
|
package/cloud-os/index.ts
CHANGED
|
@@ -84,6 +84,7 @@ export type {
|
|
|
84
84
|
export {
|
|
85
85
|
ApprovalBlock,
|
|
86
86
|
AskUserBlock,
|
|
87
|
+
AskUserAttachmentField,
|
|
87
88
|
ErrorBlock,
|
|
88
89
|
ParallelBlock,
|
|
89
90
|
PlanBlock,
|
|
@@ -94,6 +95,11 @@ export {
|
|
|
94
95
|
SuggestionsBlock,
|
|
95
96
|
} from "./chat/rich-blocks";
|
|
96
97
|
export type { ApprovalDecision } from "./chat/rich-blocks";
|
|
98
|
+
export type {
|
|
99
|
+
AskUserAttachmentController,
|
|
100
|
+
AskUserAttachmentFieldProps,
|
|
101
|
+
AskUserBlockProps,
|
|
102
|
+
} from "./chat/rich-blocks";
|
|
97
103
|
|
|
98
104
|
export { CloudOsActivityIndicator } from "./chat/activity-indicator";
|
|
99
105
|
|
|
@@ -126,6 +132,7 @@ export {
|
|
|
126
132
|
humanizeToolName,
|
|
127
133
|
toCloudOsToolCall,
|
|
128
134
|
toolNameOfPart,
|
|
135
|
+
askUserOutcome,
|
|
129
136
|
} from "./chat/tool-presentation";
|
|
130
137
|
export type {
|
|
131
138
|
CloudOsToolCall,
|
package/package.json
CHANGED
|
@@ -701,6 +701,7 @@ function CamelAskUser({
|
|
|
701
701
|
>
|
|
702
702
|
{questions.map((question, questionIndex) => {
|
|
703
703
|
const prompt = String(question.question ?? "Choose an option");
|
|
704
|
+
const attachmentQuestion = question.kind === "attachment";
|
|
704
705
|
const options = Array.isArray(question.options)
|
|
705
706
|
? question.options.filter(
|
|
706
707
|
(option): option is string => typeof option === "string",
|
|
@@ -714,10 +715,41 @@ function CamelAskUser({
|
|
|
714
715
|
(value): value is string => typeof value === "string",
|
|
715
716
|
)
|
|
716
717
|
: [];
|
|
718
|
+
const settledAttachments = Array.isArray(settledAnswer?.attachments)
|
|
719
|
+
? settledAnswer.attachments.map(recordOf).filter((attachment) =>
|
|
720
|
+
typeof attachment.name === "string" &&
|
|
721
|
+
typeof attachment.cdnUrl === "string"
|
|
722
|
+
)
|
|
723
|
+
: [];
|
|
717
724
|
return (
|
|
718
725
|
<div className="space-y-2" key={`${prompt}:${questionIndex}`}>
|
|
719
726
|
<p className="text-sm text-foreground">{prompt}</p>
|
|
720
|
-
|
|
727
|
+
{attachmentQuestion && settledAttachments.length === 0 && (
|
|
728
|
+
<p className="text-xs text-muted-foreground">
|
|
729
|
+
File upload is unavailable in this preview.
|
|
730
|
+
</p>
|
|
731
|
+
)}
|
|
732
|
+
{attachmentQuestion && settledAttachments.length > 0 && (
|
|
733
|
+
<div className="flex flex-col gap-2">
|
|
734
|
+
{settledAttachments.map((attachment) => (
|
|
735
|
+
<a
|
|
736
|
+
key={String(attachment.attachmentId ?? attachment.cdnUrl)}
|
|
737
|
+
href={attachment.cdnUrl as string}
|
|
738
|
+
target="_blank"
|
|
739
|
+
rel="noopener noreferrer"
|
|
740
|
+
className="rounded-lg border border-border px-3 py-2 text-sm text-foreground"
|
|
741
|
+
data-attachment-kind={
|
|
742
|
+
String(attachment.mediaType ?? "").startsWith("image/")
|
|
743
|
+
? "image"
|
|
744
|
+
: "file"
|
|
745
|
+
}
|
|
746
|
+
>
|
|
747
|
+
{attachment.name as string}
|
|
748
|
+
</a>
|
|
749
|
+
))}
|
|
750
|
+
</div>
|
|
751
|
+
)}
|
|
752
|
+
{!attachmentQuestion && <div className="flex flex-wrap gap-2">
|
|
721
753
|
{options.map((option) => {
|
|
722
754
|
const selected = settledAnswer
|
|
723
755
|
? settledSelections.includes(option)
|
|
@@ -751,8 +783,8 @@ function CamelAskUser({
|
|
|
751
783
|
</Button>
|
|
752
784
|
);
|
|
753
785
|
})}
|
|
754
|
-
</div>
|
|
755
|
-
{!resolvedSubmitted && allowCustom && (
|
|
786
|
+
</div>}
|
|
787
|
+
{!attachmentQuestion && !resolvedSubmitted && allowCustom && (
|
|
756
788
|
<input
|
|
757
789
|
value={answerAt(questionIndex).text}
|
|
758
790
|
maxLength={2_000}
|
|
@@ -772,7 +804,7 @@ function CamelAskUser({
|
|
|
772
804
|
className="h-8 w-full rounded-md border border-input bg-background px-3 text-sm outline-none focus:border-ring focus:ring-2 focus:ring-ring/30"
|
|
773
805
|
/>
|
|
774
806
|
)}
|
|
775
|
-
{settledAnswer && (
|
|
807
|
+
{!attachmentQuestion && settledAnswer && (
|
|
776
808
|
<p className="text-xs text-muted-foreground">
|
|
777
809
|
Answered: {[
|
|
778
810
|
...settledSelections,
|
|
@@ -788,7 +820,9 @@ function CamelAskUser({
|
|
|
788
820
|
<Button
|
|
789
821
|
type="button"
|
|
790
822
|
size="sm"
|
|
791
|
-
disabled={resolvedSubmitted || !valid
|
|
823
|
+
disabled={resolvedSubmitted || !valid || questions.some((question) =>
|
|
824
|
+
question.kind === "attachment"
|
|
825
|
+
)}
|
|
792
826
|
onClick={() => {
|
|
793
827
|
onAnswer({
|
|
794
828
|
answers: questions.map((_, index) => ({
|