@springbrand/message-panel 0.2.0-alpha.45 → 0.2.0-alpha.47
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,14 @@ 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);
|
|
213
406
|
const displayAnswerAt = (index: number): DraftAnswer => {
|
|
214
407
|
if (outcome.kind !== "answered") return answerAt(index);
|
|
215
408
|
const answer = outcome.answers[index];
|
|
409
|
+
if (!answer || !("selections" in answer)) {
|
|
410
|
+
return { selections: [], text: "" };
|
|
411
|
+
}
|
|
216
412
|
return {
|
|
217
413
|
selections: answer?.selections ?? [],
|
|
218
414
|
text: answer?.text ?? "",
|
|
@@ -221,8 +417,14 @@ export function AskUserBlock({
|
|
|
221
417
|
// 服务端已经结算 = 这张卡永久不可操作,不管结算成什么。
|
|
222
418
|
const resolved = outcome.kind !== "pending" || inFlight;
|
|
223
419
|
const valid = questions.length > 0 && questions.every(
|
|
224
|
-
(
|
|
225
|
-
|
|
420
|
+
(question, index) => question.kind === "attachment"
|
|
421
|
+
? Boolean(
|
|
422
|
+
attachmentField &&
|
|
423
|
+
attachmentField.attachments.length > 0 &&
|
|
424
|
+
!attachmentField.busy &&
|
|
425
|
+
!attachmentField.hasFailure
|
|
426
|
+
)
|
|
427
|
+
: answerAt(index).selections.length > 0 || Boolean(answerAt(index).text.trim()),
|
|
226
428
|
);
|
|
227
429
|
const buttonLabel = inFlight ? "Submitting…" : "Submit";
|
|
228
430
|
const footnote = outcome.kind === "closed"
|
|
@@ -233,16 +435,33 @@ export function AskUserBlock({
|
|
|
233
435
|
|
|
234
436
|
const submit = () => {
|
|
235
437
|
if (!onRespond) return;
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
438
|
+
setSubmissionError(false);
|
|
439
|
+
try {
|
|
440
|
+
const response = {
|
|
441
|
+
answers: questions.map((question, index) =>
|
|
442
|
+
question.kind === "attachment"
|
|
443
|
+
? { attachments: [...(attachmentField?.getParts() ?? [])] }
|
|
444
|
+
: {
|
|
445
|
+
selections: answerAt(index).selections,
|
|
446
|
+
text: answerAt(index).text.trim(),
|
|
447
|
+
}
|
|
448
|
+
),
|
|
449
|
+
};
|
|
450
|
+
setInFlight(true);
|
|
451
|
+
// 失败就把按钮放回可点:权威状态由 part 决定,这里只管别把用户锁死。
|
|
452
|
+
void onRespond(call.toolCallId, response).then((ok) => {
|
|
453
|
+
if (!ok) {
|
|
454
|
+
setInFlight(false);
|
|
455
|
+
setSubmissionError(true);
|
|
456
|
+
}
|
|
457
|
+
}, () => {
|
|
458
|
+
setInFlight(false);
|
|
459
|
+
setSubmissionError(true);
|
|
460
|
+
});
|
|
461
|
+
} catch {
|
|
462
|
+
setInFlight(false);
|
|
463
|
+
setSubmissionError(true);
|
|
464
|
+
}
|
|
246
465
|
};
|
|
247
466
|
|
|
248
467
|
return (
|
|
@@ -254,6 +473,9 @@ export function AskUserBlock({
|
|
|
254
473
|
<div className="flex flex-col gap-4 py-2.5">
|
|
255
474
|
{outcome.kind !== "closed" && questions.map((question, questionIndex) => {
|
|
256
475
|
const answer = displayAnswerAt(questionIndex);
|
|
476
|
+
const settledAttachmentAnswer = outcome.kind === "answered"
|
|
477
|
+
? outcome.answers[questionIndex]
|
|
478
|
+
: undefined;
|
|
257
479
|
const options = outcome.kind === "answered"
|
|
258
480
|
? [...new Set([...question.options, ...answer.selections])]
|
|
259
481
|
: question.options;
|
|
@@ -265,7 +487,29 @@ export function AskUserBlock({
|
|
|
265
487
|
<p className="m-0 text-[14px] leading-[1.4] font-normal text-kumo-default">
|
|
266
488
|
{question.prompt}
|
|
267
489
|
</p>
|
|
268
|
-
{
|
|
490
|
+
{question.kind === "attachment" && (
|
|
491
|
+
<AskUserAttachmentField
|
|
492
|
+
attachments={
|
|
493
|
+
settledAttachmentAnswer && "attachments" in settledAttachmentAnswer
|
|
494
|
+
? settledAttachmentAnswer.attachments.map((attachment) => ({
|
|
495
|
+
id: attachment.attachmentId,
|
|
496
|
+
filename: attachment.name,
|
|
497
|
+
mediaType: attachment.mediaType,
|
|
498
|
+
size: attachment.size,
|
|
499
|
+
previewUrl: attachment.cdnUrl,
|
|
500
|
+
status: "ready" as const,
|
|
501
|
+
}))
|
|
502
|
+
: attachmentField?.attachments ?? []
|
|
503
|
+
}
|
|
504
|
+
disabled={resolved || !onRespond || !attachmentField}
|
|
505
|
+
busy={attachmentField?.busy ?? false}
|
|
506
|
+
hasFailure={attachmentField?.hasFailure ?? false}
|
|
507
|
+
onFilesSelected={attachmentField?.onFilesSelected ?? (() => undefined)}
|
|
508
|
+
onRemove={attachmentField?.onRemove ?? (() => undefined)}
|
|
509
|
+
onRetry={attachmentField?.onRetry ?? (() => undefined)}
|
|
510
|
+
/>
|
|
511
|
+
)}
|
|
512
|
+
{question.kind === "ordinary" && options.length > 0 && (
|
|
269
513
|
<div className="flex flex-wrap items-center gap-2">
|
|
270
514
|
{options.map((option) => {
|
|
271
515
|
const selected = answer.selections.includes(option);
|
|
@@ -297,7 +541,7 @@ export function AskUserBlock({
|
|
|
297
541
|
})}
|
|
298
542
|
</div>
|
|
299
543
|
)}
|
|
300
|
-
{question.allowCustom && (
|
|
544
|
+
{question.kind === "ordinary" && question.allowCustom && (
|
|
301
545
|
<label className="flex w-full flex-col gap-2 text-[14px] leading-[1.4] text-kumo-default">
|
|
302
546
|
<span>{question.options.length > 0 ? "Other" : "Answer"}</span>
|
|
303
547
|
<input
|
|
@@ -315,7 +559,7 @@ export function AskUserBlock({
|
|
|
315
559
|
/>
|
|
316
560
|
</label>
|
|
317
561
|
)}
|
|
318
|
-
{outcome.kind === "answered" &&
|
|
562
|
+
{question.kind === "ordinary" && outcome.kind === "answered" &&
|
|
319
563
|
!question.allowCustom && answer.text.trim() && (
|
|
320
564
|
<p className="m-0 text-[14px] leading-[1.4] text-kumo-subtle">
|
|
321
565
|
{answer.text.trim()}
|
|
@@ -325,6 +569,7 @@ export function AskUserBlock({
|
|
|
325
569
|
);
|
|
326
570
|
})}
|
|
327
571
|
{outcome.kind === "pending" && (
|
|
572
|
+
<>
|
|
328
573
|
<button
|
|
329
574
|
type="button"
|
|
330
575
|
disabled={resolved || !onRespond || !valid}
|
|
@@ -333,6 +578,12 @@ export function AskUserBlock({
|
|
|
333
578
|
>
|
|
334
579
|
{buttonLabel}
|
|
335
580
|
</button>
|
|
581
|
+
{submissionError && (
|
|
582
|
+
<p className="m-0 text-[12px] leading-4 text-kumo-danger" role="alert">
|
|
583
|
+
Could not add these files to the Workspace. Try submitting again.
|
|
584
|
+
</p>
|
|
585
|
+
)}
|
|
586
|
+
</>
|
|
336
587
|
)}
|
|
337
588
|
{outcome.kind === "answered" && (
|
|
338
589
|
<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/file-view.tsx
CHANGED
|
@@ -22,7 +22,10 @@ import {
|
|
|
22
22
|
useRef,
|
|
23
23
|
useState,
|
|
24
24
|
type ComponentType,
|
|
25
|
+
type ReactNode,
|
|
25
26
|
} from "react";
|
|
27
|
+
import { PhotoProvider, PhotoView } from "react-photo-view";
|
|
28
|
+
import "react-photo-view/dist/react-photo-view.css";
|
|
26
29
|
import {
|
|
27
30
|
PRODUCT_PRESETS,
|
|
28
31
|
productTypeForMediaType,
|
|
@@ -238,6 +241,10 @@ function ProductFileView({
|
|
|
238
241
|
>
|
|
239
242
|
{content}
|
|
240
243
|
</button>
|
|
244
|
+
) : image ? (
|
|
245
|
+
<ImagePreview src={file.url!} label={label} placement={placement}>
|
|
246
|
+
{content}
|
|
247
|
+
</ImagePreview>
|
|
241
248
|
) : file.url && !video ? (
|
|
242
249
|
<a
|
|
243
250
|
href={file.url}
|
|
@@ -263,6 +270,35 @@ function ProductFileView({
|
|
|
263
270
|
);
|
|
264
271
|
}
|
|
265
272
|
|
|
273
|
+
function ImagePreview({
|
|
274
|
+
src,
|
|
275
|
+
label,
|
|
276
|
+
placement,
|
|
277
|
+
children,
|
|
278
|
+
}: {
|
|
279
|
+
src: string;
|
|
280
|
+
label: string;
|
|
281
|
+
placement: Exclude<FileViewPlacement, "composer">;
|
|
282
|
+
children: ReactNode;
|
|
283
|
+
}) {
|
|
284
|
+
return (
|
|
285
|
+
<PhotoProvider>
|
|
286
|
+
<PhotoView src={src}>
|
|
287
|
+
<button
|
|
288
|
+
type="button"
|
|
289
|
+
aria-label={`Preview ${label}`}
|
|
290
|
+
data-image-preview=""
|
|
291
|
+
data-placement={placement}
|
|
292
|
+
data-file-variant="product"
|
|
293
|
+
className="inline-block max-w-full cursor-zoom-in rounded-2xl text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/40"
|
|
294
|
+
>
|
|
295
|
+
{children}
|
|
296
|
+
</button>
|
|
297
|
+
</PhotoView>
|
|
298
|
+
</PhotoProvider>
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
266
302
|
/**
|
|
267
303
|
* Cloud OS 的默认文件视图。宿主可通过 `renderFile` 在同一 seam 替换它。
|
|
268
304
|
*/
|
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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@springbrand/message-panel",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
3
|
+
"version": "0.2.0-alpha.47",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"src",
|
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
"lucide-react": "^1.24.0",
|
|
37
37
|
"motion": "^13.1.0",
|
|
38
38
|
"radix-ui": "^1.6.7",
|
|
39
|
+
"react-photo-view": "^1.2.7",
|
|
39
40
|
"streamdown": "^2.5.0",
|
|
40
41
|
"thinking-orbs": "0.2.0",
|
|
41
42
|
"use-stick-to-bottom": "^1.1.6"
|
|
@@ -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) => ({
|