@springbrand/message-panel 0.1.3-alpha.2 → 0.1.3-alpha.4
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/README.md +4 -4
- package/cloud-os/capability-chip.tsx +35 -0
- package/cloud-os/chat/activity-indicator.tsx +29 -0
- package/cloud-os/chat/cloud-os-chat-messages.tsx +55 -17
- package/cloud-os/chat/rich-blocks.tsx +115 -83
- package/cloud-os/chat/tool-presentation.ts +42 -0
- package/cloud-os/chat/transcript-model.ts +124 -17
- package/cloud-os/composer/cloud-os-chat-input.tsx +184 -24
- package/cloud-os/index.ts +9 -0
- package/cloud-os/layout/cloud-os-workspace-split.tsx +50 -24
- package/cloud-os/styles/cloud-os.css +14 -20
- package/demo/cloud-os-chat-showcase.tsx +6 -6
- package/package.json +1 -1
- package/src/camel/camel-chat-messages.tsx +5 -0
- package/src/camel/camel-prompt-input.tsx +2 -1
- package/src/chat-summary-panel.tsx +1 -1
- package/src/composer/chat-composer.tsx +2 -1
- package/src/composer/composer-trigger-popover.tsx +1 -1
- package/src/composer/composer.tsx +2 -1
- package/src/composer/index.ts +1 -0
- package/src/composer/key-rules.ts +12 -0
- package/src/styles/index.css +4 -1
package/cloud-os/README.md
CHANGED
|
@@ -46,10 +46,10 @@ pnpm dev:cloud-os # http://localhost:9992
|
|
|
46
46
|
shadcn 主题正面冲突。
|
|
47
47
|
4. **作用域化主题**。只有 kumo 命名空间的 token 进 `@theme`;通用 token(字体 /
|
|
48
48
|
圆角 / 字号 / 缓动)挂在 `.cloud-os-root` 上靠继承生效,宿主主题分毫不动。
|
|
49
|
-
5. **删掉的东西**:capsule(URL → 资源胶囊)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
49
|
+
5. **删掉的东西**:capsule(URL → 资源胶囊)、ComposerMirror、GatekeeperModal、
|
|
50
|
+
Yjs 草稿 / 「接受改动」那一整套。它们都直挂 gadgets 的 capnweb RPC,
|
|
51
|
+
UIMessage 协议下没有对应物。命令补全使用 package 内的 assistant-ui
|
|
52
|
+
`ComposerTriggerPopover`,宿主只传命令数据。
|
|
53
53
|
6. **窄栏适配**。授权卡 / 提问卡 / 定时卡在上游是「图标 + 正文 + 右侧动作」一行到底;
|
|
54
54
|
聊天栏默认只有 420px,一行放不下会把正文挤成 0 宽,所以改成允许换行。
|
|
55
55
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Diamond, X } from "@phosphor-icons/react";
|
|
2
|
+
|
|
3
|
+
export interface CapabilityChipProps {
|
|
4
|
+
kind: "skill" | "plan";
|
|
5
|
+
label: string;
|
|
6
|
+
removable?: boolean;
|
|
7
|
+
onRemove?: () => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function CapabilityChip({
|
|
11
|
+
kind,
|
|
12
|
+
label,
|
|
13
|
+
removable = false,
|
|
14
|
+
onRemove,
|
|
15
|
+
}: CapabilityChipProps) {
|
|
16
|
+
return (
|
|
17
|
+
<span
|
|
18
|
+
data-capability-kind={kind}
|
|
19
|
+
className="inline-flex max-w-full items-center gap-1 rounded-full border border-kumo-line bg-kumo-elevated px-2 py-1 text-[12px] leading-4 text-kumo-subtle"
|
|
20
|
+
>
|
|
21
|
+
<Diamond size={11} weight="duotone" className="shrink-0 text-kumo-brand" />
|
|
22
|
+
<span className="truncate">{label}</span>
|
|
23
|
+
{removable && onRemove && (
|
|
24
|
+
<button
|
|
25
|
+
type="button"
|
|
26
|
+
onClick={onRemove}
|
|
27
|
+
aria-label={`Remove ${label}`}
|
|
28
|
+
className="-mr-1 grid size-5 shrink-0 cursor-pointer place-items-center rounded-full 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"
|
|
29
|
+
>
|
|
30
|
+
<X size={10} weight="bold" />
|
|
31
|
+
</button>
|
|
32
|
+
)}
|
|
33
|
+
</span>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { ThinkingOrb } from "thinking-orbs";
|
|
2
|
+
import { useCloudOsMode } from "../internal/theme-context";
|
|
3
|
+
import type { CloudOsActivity } from "./transcript-model";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 回合还在跑、但屏幕上没有任何东西在动时的活动指示。
|
|
7
|
+
*
|
|
8
|
+
* 用 thinking-orbs 的 canvas 球而不是一行 shimmer 文字:动画本身就是「还活着」
|
|
9
|
+
* 的信号,文案只负责说清在干什么(`deriveTurnActivity` 给出的四种之一)。
|
|
10
|
+
*
|
|
11
|
+
* 和 `src/status.tsx` 的 `ThinkingIndicator` 是同一个上游球、同一套 role/aria
|
|
12
|
+
* 形状,但**不共用实现** —— cloud-os 不 import `../src`(见 cloud-os/README.md,
|
|
13
|
+
* 由 check-chat-presentation-boundary.mjs 守着),文案也走 cloud-os 自己的
|
|
14
|
+
* 工具种类表,不是 src 那套工具名正则。
|
|
15
|
+
*/
|
|
16
|
+
export function CloudOsActivityIndicator({ state, label }: CloudOsActivity) {
|
|
17
|
+
// 必须显式传 theme,不能用它的 `auto`:auto 先扫祖先的 data-theme / .dark / .light,
|
|
18
|
+
// 而本仓的浅色**是「没有 .dark」**,扫不到任何标记 → 它回落到
|
|
19
|
+
// prefers-color-scheme,也就是**操作系统**的深浅。系统深色 + 应用浅色时,
|
|
20
|
+
// 球会在浅底上画浅墨,几乎看不见。cloud-os 子树的深浅事实源是这个 context
|
|
21
|
+
// (CloudOsRoot 从宿主的 useThemeMode() 拿到),直接用它。
|
|
22
|
+
const mode = useCloudOsMode();
|
|
23
|
+
return (
|
|
24
|
+
<div className="cos-activity" role="status" aria-label={label}>
|
|
25
|
+
<ThinkingOrb state={state} size={20} theme={mode} aria-hidden="true" />
|
|
26
|
+
<span>{label}</span>
|
|
27
|
+
</div>
|
|
28
|
+
);
|
|
29
|
+
}
|
|
@@ -9,11 +9,13 @@ import {
|
|
|
9
9
|
Terminal,
|
|
10
10
|
} from "@phosphor-icons/react";
|
|
11
11
|
import {
|
|
12
|
+
Fragment,
|
|
12
13
|
useCallback,
|
|
13
14
|
useEffect,
|
|
14
15
|
useMemo,
|
|
15
16
|
useRef,
|
|
16
17
|
useState,
|
|
18
|
+
type ReactNode,
|
|
17
19
|
} from "react";
|
|
18
20
|
import { Tooltip } from "../primitives/tooltip";
|
|
19
21
|
import { WorkshopIconButton } from "../primitives/workshop-controls";
|
|
@@ -33,8 +35,11 @@ import {
|
|
|
33
35
|
type ApprovalDecision,
|
|
34
36
|
} from "./rich-blocks";
|
|
35
37
|
import { ThinkingTraceRow, ToolGroupRow } from "./tool-rows";
|
|
38
|
+
import { CloudOsActivityIndicator } from "./activity-indicator";
|
|
39
|
+
import { CapabilityChip } from "../capability-chip";
|
|
36
40
|
import {
|
|
37
41
|
buildCloudOsEntries,
|
|
42
|
+
deriveTurnActivity,
|
|
38
43
|
formatClockTime,
|
|
39
44
|
formatFullTimestamp,
|
|
40
45
|
rhythmTopClass,
|
|
@@ -58,6 +63,18 @@ export interface CloudOsChatMessagesProps {
|
|
|
58
63
|
/** 把某个 part 映射成待授权 id;返回 undefined 表示不在此处渲染授权。 */
|
|
59
64
|
approvalIdOf?: (part: unknown) => string | undefined;
|
|
60
65
|
approvalsDisabled?: boolean;
|
|
66
|
+
/**
|
|
67
|
+
* 用宿主自己的卡片渲染某条待授权。返回 undefined 就退回内置的通用授权行。
|
|
68
|
+
*
|
|
69
|
+
* 存在的理由:授权行只认得工具名和入参,说不出「这次要花多少钱、买的是什么」。
|
|
70
|
+
* 需要在决定之前把这些摆到人面前的宿主,得自己画那张卡。
|
|
71
|
+
*/
|
|
72
|
+
renderApproval?: (approval: {
|
|
73
|
+
approvalId: string;
|
|
74
|
+
toolName: string;
|
|
75
|
+
input: Record<string, unknown>;
|
|
76
|
+
disabled: boolean;
|
|
77
|
+
}) => ReactNode;
|
|
61
78
|
/** 把消息里的 `sandbox:/workspace/...` 之类地址翻译成可访问 URL。 */
|
|
62
79
|
resolveUrl?: CloudOsUrlResolver;
|
|
63
80
|
/** 消息区宽度是否收敛到 920px 居中(GadgetEditor 里叫 constrainChatWidth)。 */
|
|
@@ -66,7 +83,11 @@ export interface CloudOsChatMessagesProps {
|
|
|
66
83
|
onRetry?: () => void;
|
|
67
84
|
onApprove?: (approvalId: string, decision: ApprovalDecision) => void;
|
|
68
85
|
onSuggestion?: (text: string) => void;
|
|
69
|
-
|
|
86
|
+
/**
|
|
87
|
+
* 把交互卡片上的操作回写成那次工具调用的结果(缺省 → 卡片不可操作)。
|
|
88
|
+
* 卡片不再知道「答案怎么变成消息」—— 它只认自己的 toolCallId。
|
|
89
|
+
*/
|
|
90
|
+
onRespond?: (toolCallId: string, response: unknown) => Promise<boolean>;
|
|
70
91
|
/** 点击定时任务卡片:宿主决定跳到哪(缺省 → 卡片不可点)。 */
|
|
71
92
|
onOpenSchedule?: (scheduleId: string) => void;
|
|
72
93
|
onCopy?: (text: string) => void;
|
|
@@ -107,6 +128,17 @@ function UserBubble({
|
|
|
107
128
|
}) {
|
|
108
129
|
return (
|
|
109
130
|
<div className="group/message relative flex flex-col items-end">
|
|
131
|
+
{entry.capabilities.length > 0 && (
|
|
132
|
+
<div className="mb-1.5 flex max-w-[min(680px,78%)] flex-wrap justify-end gap-1.5">
|
|
133
|
+
{entry.capabilities.map((capability) => (
|
|
134
|
+
<CapabilityChip
|
|
135
|
+
key={`${capability.kind}:${capability.name}`}
|
|
136
|
+
kind={capability.kind}
|
|
137
|
+
label={capability.label}
|
|
138
|
+
/>
|
|
139
|
+
))}
|
|
140
|
+
</div>
|
|
141
|
+
)}
|
|
110
142
|
{entry.attachments.length > 0 && (
|
|
111
143
|
<div className="mb-1.5 flex max-w-[min(680px,78%)] flex-wrap justify-end gap-2">
|
|
112
144
|
{entry.attachments.map((attachment, index) =>
|
|
@@ -173,13 +205,14 @@ export function CloudOsChatMessages({
|
|
|
173
205
|
showThinkingTraces = true,
|
|
174
206
|
approvalIdOf,
|
|
175
207
|
approvalsDisabled = false,
|
|
208
|
+
renderApproval,
|
|
176
209
|
resolveUrl,
|
|
177
210
|
constrainWidth = true,
|
|
178
211
|
emptyTitle = "What are we working on?",
|
|
179
212
|
onRetry,
|
|
180
213
|
onApprove,
|
|
181
214
|
onSuggestion,
|
|
182
|
-
|
|
215
|
+
onRespond,
|
|
183
216
|
onOpenSchedule,
|
|
184
217
|
onCopy,
|
|
185
218
|
}: CloudOsChatMessagesProps) {
|
|
@@ -246,14 +279,10 @@ export function CloudOsChatMessages({
|
|
|
246
279
|
element.scrollTo({ top: element.scrollHeight });
|
|
247
280
|
}, [isRecovering, messages, status, showScrollButton]);
|
|
248
281
|
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
|
|
252
|
-
const
|
|
253
|
-
isActive &&
|
|
254
|
-
(lastEntry === null ||
|
|
255
|
-
lastEntry.type === "user" ||
|
|
256
|
-
lastEntry.type === "slashCommand");
|
|
282
|
+
// 判据是「尾巴有没有活口」,不是原文件的「本回合有没有出过内容」——
|
|
283
|
+
// 后者会让「答完 ask_user 之后到模型下一个 chunk」这段完全没有指示,
|
|
284
|
+
// 而卡片此时写着 Submitted,读起来就是卡死了。见 deriveTurnActivity。
|
|
285
|
+
const activity = deriveTurnActivity(entries, { isActive, hasPendingSteer });
|
|
257
286
|
const copyText = (text: string) => {
|
|
258
287
|
copy(text);
|
|
259
288
|
onCopy?.(text);
|
|
@@ -432,8 +461,7 @@ export function CloudOsChatMessages({
|
|
|
432
461
|
<AskUserBlock
|
|
433
462
|
key={block.key}
|
|
434
463
|
call={block.call}
|
|
435
|
-
|
|
436
|
-
onAnswer={(payload) => onAnswer?.(payload)}
|
|
464
|
+
onRespond={onRespond}
|
|
437
465
|
/>
|
|
438
466
|
);
|
|
439
467
|
case "suggestions":
|
|
@@ -452,7 +480,18 @@ export function CloudOsChatMessages({
|
|
|
452
480
|
onOpen={onOpenSchedule}
|
|
453
481
|
/>
|
|
454
482
|
);
|
|
455
|
-
case "approval":
|
|
483
|
+
case "approval": {
|
|
484
|
+
const custom = renderApproval?.({
|
|
485
|
+
approvalId: block.approvalId,
|
|
486
|
+
toolName: block.call.toolName,
|
|
487
|
+
input: block.call.input,
|
|
488
|
+
disabled: approvalsDisabled,
|
|
489
|
+
});
|
|
490
|
+
if (custom !== undefined && custom !== null) {
|
|
491
|
+
return (
|
|
492
|
+
<Fragment key={block.key}>{custom}</Fragment>
|
|
493
|
+
);
|
|
494
|
+
}
|
|
456
495
|
return (
|
|
457
496
|
<ApprovalBlock
|
|
458
497
|
key={block.key}
|
|
@@ -462,6 +501,7 @@ export function CloudOsChatMessages({
|
|
|
462
501
|
onApprove={(id, decision) => onApprove?.(id, decision)}
|
|
463
502
|
/>
|
|
464
503
|
);
|
|
504
|
+
}
|
|
465
505
|
case "parallel":
|
|
466
506
|
return (
|
|
467
507
|
<ParallelBlock
|
|
@@ -551,11 +591,9 @@ export function CloudOsChatMessages({
|
|
|
551
591
|
</div>
|
|
552
592
|
)}
|
|
553
593
|
|
|
554
|
-
{
|
|
594
|
+
{activity && (
|
|
555
595
|
<div className="mt-5 inline-flex px-1.5 py-1 text-[14px] leading-5 tracking-[-0.25px]">
|
|
556
|
-
<
|
|
557
|
-
{hasPendingSteer ? "Waiting for the current step to finish…" : "Thinking"}
|
|
558
|
-
</span>
|
|
596
|
+
<CloudOsActivityIndicator {...activity} />
|
|
559
597
|
</div>
|
|
560
598
|
)}
|
|
561
599
|
|
|
@@ -18,7 +18,7 @@ import type {
|
|
|
18
18
|
PlanStep,
|
|
19
19
|
SubAgentView,
|
|
20
20
|
} from "./transcript-model";
|
|
21
|
-
import type
|
|
21
|
+
import { askUserOutcome, type CloudOsToolCall } from "./tool-presentation";
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* UIMessage 协议里存在、gadgets 那套没有的几类 part —— 计划快照、原位提问、
|
|
@@ -149,99 +149,131 @@ function questionsOf(input: Record<string, unknown>): AskQuestion[] {
|
|
|
149
149
|
|
|
150
150
|
export function AskUserBlock({
|
|
151
151
|
call,
|
|
152
|
-
|
|
153
|
-
onAnswer,
|
|
152
|
+
onRespond,
|
|
154
153
|
}: {
|
|
155
154
|
call: CloudOsToolCall;
|
|
156
|
-
|
|
157
|
-
|
|
155
|
+
/** 缺省(宿主没接)时选项不可点 —— 形状对齐 ScheduleBlock 的 onOpen。 */
|
|
156
|
+
onRespond?: (toolCallId: string, response: unknown) => Promise<boolean>;
|
|
158
157
|
}) {
|
|
159
158
|
const questions = questionsOf(call.input);
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const [
|
|
164
|
-
const
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
159
|
+
// 不能用 `useState(() => questions.map(() => []))` 初始化:工具入参是**流式**到达的,
|
|
160
|
+
// 首帧 questions 往往还是空数组,那之后每次 setSelections 都在空数组上 map,
|
|
161
|
+
// 选择永远存不进去 → Submit 永久禁用。改成稀疏存 + 按当前 questions 补齐。
|
|
162
|
+
const [selections, setSelections] = useState<string[][]>([]);
|
|
163
|
+
const selectionAt = (index: number) => selections[index] ?? [];
|
|
164
|
+
const toggle = (questionIndex: number, option: string, multi: boolean) =>
|
|
165
|
+
setSelections((current) => {
|
|
166
|
+
const next = questions.map((_, index) => current[index] ?? []);
|
|
167
|
+
const values = next[questionIndex] ?? [];
|
|
168
|
+
next[questionIndex] = !multi
|
|
169
|
+
? [option]
|
|
170
|
+
: values.includes(option)
|
|
171
|
+
? values.filter((value) => value !== option)
|
|
172
|
+
: [...values, option];
|
|
173
|
+
return next;
|
|
174
|
+
});
|
|
175
|
+
// 在途只用于按钮转圈;权威状态永远是 part 的 state。
|
|
176
|
+
const [inFlight, setInFlight] = useState(false);
|
|
177
|
+
const outcome = askUserOutcome(call);
|
|
178
|
+
const answer = outcome.kind === "answered" ? outcome : null;
|
|
179
|
+
const answeredSelections = new Set(answer?.selections ?? []);
|
|
180
|
+
const answeredText = [
|
|
181
|
+
...(answer?.selections ?? []),
|
|
182
|
+
...(answer?.text.trim() ? [answer.text.trim()] : []),
|
|
183
|
+
].join(" / ");
|
|
184
|
+
// 服务端已经结算 = 这张卡永久不可操作,不管结算成什么。
|
|
185
|
+
const resolved = outcome.kind !== "pending" || inFlight;
|
|
186
|
+
const valid = questions.length > 0 && questions.every(
|
|
170
187
|
(question, index) =>
|
|
171
|
-
question.options.length === 0 || (
|
|
188
|
+
question.options.length === 0 || selectionAt(index).length > 0,
|
|
189
|
+
);
|
|
190
|
+
const buttonLabel = outcome.kind === "answered"
|
|
191
|
+
? "Submitted"
|
|
192
|
+
: outcome.kind === "closed"
|
|
193
|
+
? "Closed"
|
|
194
|
+
: inFlight
|
|
195
|
+
? "Submitting…"
|
|
196
|
+
: "Submit";
|
|
197
|
+
const footnote = outcome.kind === "answered"
|
|
198
|
+
? answeredText && `Answered: ${answeredText}`
|
|
199
|
+
: outcome.kind === "closed"
|
|
200
|
+
? outcome.reason === "user_replied_freeform"
|
|
201
|
+
? "Answered in the chat instead."
|
|
202
|
+
: "Closed without an answer."
|
|
203
|
+
: "";
|
|
204
|
+
|
|
205
|
+
// 提交按钮固定在卡片右下角,和脚注同一行 —— 脚注不再单独占一行,多问题时
|
|
206
|
+
// 按钮也不会卡在某一题的选项中间。选项没到(流式首帧 questions 为空)时这行
|
|
207
|
+
// 照样渲染,否则用户看到的是一张空卡。
|
|
208
|
+
const submit = (
|
|
209
|
+
<WorkshopButton
|
|
210
|
+
tone="primary"
|
|
211
|
+
disabled={resolved || !valid || !onRespond}
|
|
212
|
+
onClick={() => {
|
|
213
|
+
if (!onRespond) return;
|
|
214
|
+
setInFlight(true);
|
|
215
|
+
// 失败就把按钮放回可点:权威状态由 part 决定,这里只管别把用户锁死。
|
|
216
|
+
void onRespond(call.toolCallId, {
|
|
217
|
+
selections: questions.flatMap((_, index) => selectionAt(index)),
|
|
218
|
+
text: "",
|
|
219
|
+
}).then((ok) => {
|
|
220
|
+
if (!ok) setInFlight(false);
|
|
221
|
+
});
|
|
222
|
+
}}
|
|
223
|
+
className="ml-auto !h-7 flex-shrink-0 gap-1 !rounded-lg px-2.5 text-[12px]"
|
|
224
|
+
>
|
|
225
|
+
{buttonLabel}
|
|
226
|
+
</WorkshopButton>
|
|
172
227
|
);
|
|
173
228
|
|
|
174
229
|
return (
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
230
|
+
// 收成 w-fit:短问题不该撑成一条横贯全栏的长条,长问题到 560px 再换行。
|
|
231
|
+
<div className="w-fit min-w-[15rem] max-w-[560px]">
|
|
232
|
+
{/* 组内(问题↔选项)10px、组间(题与题、题与按钮行)12px —— 组间必须比组内
|
|
233
|
+
松,否则多问题时上一题的选项会看起来像下一题的。
|
|
234
|
+
用 flex+gap 而不是 space-y-*:Tailwind v4 的 space-y 选择器裹在
|
|
235
|
+
`:where()` 里,特异性被抹平,会被子元素自己的 `m-0` 压掉 → 不生效。 */}
|
|
236
|
+
<div className="flex flex-col gap-3 rounded-xl border border-kumo-line bg-kumo-base px-3 py-2.5">
|
|
237
|
+
{questions.map((question, questionIndex) => (
|
|
238
|
+
<div
|
|
239
|
+
key={`${question.prompt}:${questionIndex}`}
|
|
240
|
+
className="flex flex-col gap-2.5"
|
|
182
241
|
>
|
|
183
|
-
<
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
: [...values, option];
|
|
209
|
-
}),
|
|
210
|
-
)
|
|
211
|
-
}
|
|
212
|
-
className={`inline-flex h-7 cursor-pointer items-center rounded-lg border px-2.5 text-[13px] leading-none tracking-[-0.25px] transition-colors duration-150 ease-out disabled:cursor-default ${
|
|
213
|
-
selected
|
|
214
|
-
? "border-kumo-brand/45 bg-kumo-brand/10 text-kumo-default"
|
|
215
|
-
: "border-kumo-line bg-kumo-base text-kumo-subtle hover:bg-kumo-tint hover:text-kumo-default"
|
|
216
|
-
}`}
|
|
217
|
-
>
|
|
218
|
-
{option}
|
|
219
|
-
</button>
|
|
220
|
-
);
|
|
221
|
-
})}
|
|
222
|
-
</div>
|
|
223
|
-
</div>
|
|
224
|
-
))}
|
|
225
|
-
</div>
|
|
226
|
-
<div className="ml-auto flex flex-shrink-0 items-center self-center">
|
|
227
|
-
<WorkshopButton
|
|
228
|
-
tone="primary"
|
|
229
|
-
disabled={resolved || !valid}
|
|
230
|
-
onClick={() => {
|
|
231
|
-
onAnswer({ selections, text: "" });
|
|
232
|
-
setSubmitted(true);
|
|
233
|
-
}}
|
|
234
|
-
className="!h-8 gap-1 !rounded-lg text-[12px]"
|
|
235
|
-
>
|
|
236
|
-
{resolved ? "Submitted" : "Submit"}
|
|
237
|
-
</WorkshopButton>
|
|
242
|
+
<p className="m-0 text-[13px] leading-[18px] font-medium tracking-[-0.25px] text-kumo-default">
|
|
243
|
+
{question.prompt}
|
|
244
|
+
</p>
|
|
245
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
246
|
+
{question.options.map((option) => {
|
|
247
|
+
const selected = answer
|
|
248
|
+
? answeredSelections.has(option)
|
|
249
|
+
: selectionAt(questionIndex).includes(option);
|
|
250
|
+
return (
|
|
251
|
+
<button
|
|
252
|
+
key={option}
|
|
253
|
+
type="button"
|
|
254
|
+
disabled={resolved || !onRespond}
|
|
255
|
+
onClick={() => toggle(questionIndex, option, question.multi)}
|
|
256
|
+
className={`inline-flex h-7 cursor-pointer items-center rounded-lg border px-2.5 text-[13px] leading-none tracking-[-0.25px] transition-colors duration-150 ease-out disabled:cursor-default ${
|
|
257
|
+
selected
|
|
258
|
+
? "border-kumo-brand/45 bg-kumo-brand/10 text-kumo-default"
|
|
259
|
+
: "border-kumo-line bg-kumo-base text-kumo-subtle hover:bg-kumo-tint hover:text-kumo-default"
|
|
260
|
+
}`}
|
|
261
|
+
>
|
|
262
|
+
{option}
|
|
263
|
+
</button>
|
|
264
|
+
);
|
|
265
|
+
})}
|
|
266
|
+
</div>
|
|
238
267
|
</div>
|
|
268
|
+
))}
|
|
269
|
+
<div className="flex items-center gap-3">
|
|
270
|
+
{footnote && (
|
|
271
|
+
<p className="m-0 min-w-0 text-[12px] leading-4 text-kumo-inactive">
|
|
272
|
+
{footnote}
|
|
273
|
+
</p>
|
|
274
|
+
)}
|
|
275
|
+
{submit}
|
|
239
276
|
</div>
|
|
240
|
-
{external && (
|
|
241
|
-
<p className="mt-2 mb-0 pl-12 text-[12px] leading-4 text-kumo-inactive">
|
|
242
|
-
Answered: {external}
|
|
243
|
-
</p>
|
|
244
|
-
)}
|
|
245
277
|
</div>
|
|
246
278
|
</div>
|
|
247
279
|
);
|
|
@@ -489,6 +489,48 @@ export function toCloudOsToolCall(
|
|
|
489
489
|
};
|
|
490
490
|
}
|
|
491
491
|
|
|
492
|
+
// ── ask_user 的终局 ─────────────────────────────────────────────────────────
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* `ask_user` 这次调用的三种终局,全部只看它自己的 state 和 output。
|
|
496
|
+
*
|
|
497
|
+
* - `pending`:还等着用户操作,选项可点。**只有 input 态才算 pending** ——
|
|
498
|
+
* 服务端一旦结算,这次 interaction 就不再接受响应。
|
|
499
|
+
* - `answered`:`ask_user` 的 settle 写了 `{ selections, text }`。
|
|
500
|
+
* - `closed`:被结算掉了但不是一个答案 —— 用户改成直接发言(`{ cancelled: true }`)、
|
|
501
|
+
* Turn 提前结束、或工具出错。必须和 pending 分开,否则卡片会留一个点了拿
|
|
502
|
+
* `{ ok: false }` 的假 Submit,读起来就是「点了没反应」。
|
|
503
|
+
*
|
|
504
|
+
* 判据放在这里而不是卡片里:卡片(能不能点)和底部活动指示器(还在不在等人)
|
|
505
|
+
* 都要用它,两处各判一次迟早会分叉。
|
|
506
|
+
*/
|
|
507
|
+
export type AskUserOutcome =
|
|
508
|
+
| { kind: "pending" }
|
|
509
|
+
| { kind: "answered"; selections: string[]; text: string }
|
|
510
|
+
| { kind: "closed"; reason: "user_replied_freeform" | "other" };
|
|
511
|
+
|
|
512
|
+
export function askUserOutcome(call: CloudOsToolCall): AskUserOutcome {
|
|
513
|
+
if (call.state === "input-streaming" || call.state === "input-available") {
|
|
514
|
+
return { kind: "pending" };
|
|
515
|
+
}
|
|
516
|
+
const record = recordOf(call.output);
|
|
517
|
+
if (Array.isArray(record.selections)) {
|
|
518
|
+
return {
|
|
519
|
+
kind: "answered",
|
|
520
|
+
selections: record.selections.filter(
|
|
521
|
+
(value): value is string => typeof value === "string",
|
|
522
|
+
),
|
|
523
|
+
text: typeof record.text === "string" ? record.text : "",
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
return {
|
|
527
|
+
kind: "closed",
|
|
528
|
+
reason: record.reason === "user_replied_freeform"
|
|
529
|
+
? "user_replied_freeform"
|
|
530
|
+
: "other",
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
|
|
492
534
|
// ── 分组(逐行对应原 buildToolCallGroups)────────────────────────────────────
|
|
493
535
|
|
|
494
536
|
export function buildToolCallGroups(calls: CloudOsToolCall[]): ToolCallGroup[] {
|