@springbrand/message-panel 0.1.3-alpha.2 → 0.1.3-alpha.3
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/activity-indicator.tsx +29 -0
- package/cloud-os/chat/cloud-os-chat-messages.tsx +43 -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 +90 -17
- package/cloud-os/composer/cloud-os-chat-input.tsx +11 -2
- package/cloud-os/index.ts +4 -0
- package/cloud-os/layout/cloud-os-workspace-split.tsx +49 -9
- package/cloud-os/styles/cloud-os.css +14 -0
- package/demo/cloud-os-chat-showcase.tsx +6 -5
- 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.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
|
@@ -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,10 @@ import {
|
|
|
33
35
|
type ApprovalDecision,
|
|
34
36
|
} from "./rich-blocks";
|
|
35
37
|
import { ThinkingTraceRow, ToolGroupRow } from "./tool-rows";
|
|
38
|
+
import { CloudOsActivityIndicator } from "./activity-indicator";
|
|
36
39
|
import {
|
|
37
40
|
buildCloudOsEntries,
|
|
41
|
+
deriveTurnActivity,
|
|
38
42
|
formatClockTime,
|
|
39
43
|
formatFullTimestamp,
|
|
40
44
|
rhythmTopClass,
|
|
@@ -58,6 +62,18 @@ export interface CloudOsChatMessagesProps {
|
|
|
58
62
|
/** 把某个 part 映射成待授权 id;返回 undefined 表示不在此处渲染授权。 */
|
|
59
63
|
approvalIdOf?: (part: unknown) => string | undefined;
|
|
60
64
|
approvalsDisabled?: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* 用宿主自己的卡片渲染某条待授权。返回 undefined 就退回内置的通用授权行。
|
|
67
|
+
*
|
|
68
|
+
* 存在的理由:授权行只认得工具名和入参,说不出「这次要花多少钱、买的是什么」。
|
|
69
|
+
* 需要在决定之前把这些摆到人面前的宿主,得自己画那张卡。
|
|
70
|
+
*/
|
|
71
|
+
renderApproval?: (approval: {
|
|
72
|
+
approvalId: string;
|
|
73
|
+
toolName: string;
|
|
74
|
+
input: Record<string, unknown>;
|
|
75
|
+
disabled: boolean;
|
|
76
|
+
}) => ReactNode;
|
|
61
77
|
/** 把消息里的 `sandbox:/workspace/...` 之类地址翻译成可访问 URL。 */
|
|
62
78
|
resolveUrl?: CloudOsUrlResolver;
|
|
63
79
|
/** 消息区宽度是否收敛到 920px 居中(GadgetEditor 里叫 constrainChatWidth)。 */
|
|
@@ -66,7 +82,11 @@ export interface CloudOsChatMessagesProps {
|
|
|
66
82
|
onRetry?: () => void;
|
|
67
83
|
onApprove?: (approvalId: string, decision: ApprovalDecision) => void;
|
|
68
84
|
onSuggestion?: (text: string) => void;
|
|
69
|
-
|
|
85
|
+
/**
|
|
86
|
+
* 把交互卡片上的操作回写成那次工具调用的结果(缺省 → 卡片不可操作)。
|
|
87
|
+
* 卡片不再知道「答案怎么变成消息」—— 它只认自己的 toolCallId。
|
|
88
|
+
*/
|
|
89
|
+
onRespond?: (toolCallId: string, response: unknown) => Promise<boolean>;
|
|
70
90
|
/** 点击定时任务卡片:宿主决定跳到哪(缺省 → 卡片不可点)。 */
|
|
71
91
|
onOpenSchedule?: (scheduleId: string) => void;
|
|
72
92
|
onCopy?: (text: string) => void;
|
|
@@ -173,13 +193,14 @@ export function CloudOsChatMessages({
|
|
|
173
193
|
showThinkingTraces = true,
|
|
174
194
|
approvalIdOf,
|
|
175
195
|
approvalsDisabled = false,
|
|
196
|
+
renderApproval,
|
|
176
197
|
resolveUrl,
|
|
177
198
|
constrainWidth = true,
|
|
178
199
|
emptyTitle = "What are we working on?",
|
|
179
200
|
onRetry,
|
|
180
201
|
onApprove,
|
|
181
202
|
onSuggestion,
|
|
182
|
-
|
|
203
|
+
onRespond,
|
|
183
204
|
onOpenSchedule,
|
|
184
205
|
onCopy,
|
|
185
206
|
}: CloudOsChatMessagesProps) {
|
|
@@ -246,14 +267,10 @@ export function CloudOsChatMessages({
|
|
|
246
267
|
element.scrollTo({ top: element.scrollHeight });
|
|
247
268
|
}, [isRecovering, messages, status, showScrollButton]);
|
|
248
269
|
|
|
249
|
-
//
|
|
250
|
-
//
|
|
251
|
-
|
|
252
|
-
const
|
|
253
|
-
isActive &&
|
|
254
|
-
(lastEntry === null ||
|
|
255
|
-
lastEntry.type === "user" ||
|
|
256
|
-
lastEntry.type === "slashCommand");
|
|
270
|
+
// 判据是「尾巴有没有活口」,不是原文件的「本回合有没有出过内容」——
|
|
271
|
+
// 后者会让「答完 ask_user 之后到模型下一个 chunk」这段完全没有指示,
|
|
272
|
+
// 而卡片此时写着 Submitted,读起来就是卡死了。见 deriveTurnActivity。
|
|
273
|
+
const activity = deriveTurnActivity(entries, { isActive, hasPendingSteer });
|
|
257
274
|
const copyText = (text: string) => {
|
|
258
275
|
copy(text);
|
|
259
276
|
onCopy?.(text);
|
|
@@ -432,8 +449,7 @@ export function CloudOsChatMessages({
|
|
|
432
449
|
<AskUserBlock
|
|
433
450
|
key={block.key}
|
|
434
451
|
call={block.call}
|
|
435
|
-
|
|
436
|
-
onAnswer={(payload) => onAnswer?.(payload)}
|
|
452
|
+
onRespond={onRespond}
|
|
437
453
|
/>
|
|
438
454
|
);
|
|
439
455
|
case "suggestions":
|
|
@@ -452,7 +468,18 @@ export function CloudOsChatMessages({
|
|
|
452
468
|
onOpen={onOpenSchedule}
|
|
453
469
|
/>
|
|
454
470
|
);
|
|
455
|
-
case "approval":
|
|
471
|
+
case "approval": {
|
|
472
|
+
const custom = renderApproval?.({
|
|
473
|
+
approvalId: block.approvalId,
|
|
474
|
+
toolName: block.call.toolName,
|
|
475
|
+
input: block.call.input,
|
|
476
|
+
disabled: approvalsDisabled,
|
|
477
|
+
});
|
|
478
|
+
if (custom !== undefined && custom !== null) {
|
|
479
|
+
return (
|
|
480
|
+
<Fragment key={block.key}>{custom}</Fragment>
|
|
481
|
+
);
|
|
482
|
+
}
|
|
456
483
|
return (
|
|
457
484
|
<ApprovalBlock
|
|
458
485
|
key={block.key}
|
|
@@ -462,6 +489,7 @@ export function CloudOsChatMessages({
|
|
|
462
489
|
onApprove={(id, decision) => onApprove?.(id, decision)}
|
|
463
490
|
/>
|
|
464
491
|
);
|
|
492
|
+
}
|
|
465
493
|
case "parallel":
|
|
466
494
|
return (
|
|
467
495
|
<ParallelBlock
|
|
@@ -551,11 +579,9 @@ export function CloudOsChatMessages({
|
|
|
551
579
|
</div>
|
|
552
580
|
)}
|
|
553
581
|
|
|
554
|
-
{
|
|
582
|
+
{activity && (
|
|
555
583
|
<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>
|
|
584
|
+
<CloudOsActivityIndicator {...activity} />
|
|
559
585
|
</div>
|
|
560
586
|
)}
|
|
561
587
|
|
|
@@ -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[] {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { UIMessage } from "ai";
|
|
2
|
+
import type { OrbState } from "thinking-orbs";
|
|
2
3
|
import {
|
|
4
|
+
askUserOutcome,
|
|
3
5
|
buildToolCallGroups,
|
|
4
6
|
toCloudOsToolCall,
|
|
5
7
|
type CloudOsToolCall,
|
|
@@ -45,9 +47,12 @@ export type AssistantBlock =
|
|
|
45
47
|
| { kind: "text"; key: string; text: string }
|
|
46
48
|
| { kind: "toolGroup"; key: string; group: ToolCallGroup }
|
|
47
49
|
| { kind: "plan"; key: string; steps: PlanStep[]; running: boolean }
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
50
|
+
// 答案就在 call.output 里(`ask_user` 是 client-settled tool,用户点选后
|
|
51
|
+
// 经 respondToolInteraction 回写成这次调用的结果)。曾经靠扫下一条用户消息
|
|
52
|
+
// 推断,那是有损且会误认无关消息的,已彻底删除。
|
|
53
|
+
// `pending` 是这张卡还在等人 —— 卡片据此决定能不能点,活动指示器据此决定
|
|
54
|
+
// 该不该出现,同一条判据(askUserOutcome)算一次。
|
|
55
|
+
| { kind: "askUser"; key: string; call: CloudOsToolCall; pending: boolean }
|
|
51
56
|
| { kind: "suggestions"; key: string; items: string[] }
|
|
52
57
|
| { kind: "schedule"; key: string; call: CloudOsToolCall }
|
|
53
58
|
| { kind: "approval"; key: string; call: CloudOsToolCall; approvalId: string }
|
|
@@ -248,7 +253,6 @@ function assistantBlocks(
|
|
|
248
253
|
message: UIMessage,
|
|
249
254
|
isActive: boolean,
|
|
250
255
|
showThinkingTraces: boolean,
|
|
251
|
-
replyText: string | undefined,
|
|
252
256
|
approvalIdOf: (part: unknown) => string | undefined,
|
|
253
257
|
): AssistantBlock[] {
|
|
254
258
|
const blocks: AssistantBlock[] = [];
|
|
@@ -380,10 +384,7 @@ function assistantBlocks(
|
|
|
380
384
|
kind: "askUser",
|
|
381
385
|
key,
|
|
382
386
|
call,
|
|
383
|
-
|
|
384
|
-
replyText?.trim() ||
|
|
385
|
-
(typeof call.output === "string" ? call.output.trim() : "") ||
|
|
386
|
-
undefined,
|
|
387
|
+
pending: askUserOutcome(call).kind === "pending",
|
|
387
388
|
});
|
|
388
389
|
return;
|
|
389
390
|
}
|
|
@@ -432,13 +433,6 @@ function terminalOf(
|
|
|
432
433
|
return undefined;
|
|
433
434
|
}
|
|
434
435
|
|
|
435
|
-
function isDirectUserMessage(message: UIMessage): boolean {
|
|
436
|
-
return (
|
|
437
|
-
message.role === "user" &&
|
|
438
|
-
!message.parts.some((part) => dataKindOf(part) !== null)
|
|
439
|
-
);
|
|
440
|
-
}
|
|
441
|
-
|
|
442
436
|
export interface BuildEntriesOptions {
|
|
443
437
|
messages: readonly UIMessage[];
|
|
444
438
|
/** streaming/submitted 时最后一条 assistant 消息才算「活着」。 */
|
|
@@ -502,12 +496,10 @@ export function buildCloudOsEntries({
|
|
|
502
496
|
return;
|
|
503
497
|
}
|
|
504
498
|
|
|
505
|
-
const reply = messages.slice(index + 1).find(isDirectUserMessage);
|
|
506
499
|
const blocks = assistantBlocks(
|
|
507
500
|
message,
|
|
508
501
|
isActive && index === lastAssistantIndex,
|
|
509
502
|
showThinkingTraces,
|
|
510
|
-
reply ? messageText(reply).trim() || undefined : undefined,
|
|
511
503
|
approvalIdOf,
|
|
512
504
|
);
|
|
513
505
|
const terminal = terminalOf(message);
|
|
@@ -560,6 +552,87 @@ function dropSupersededPlans(entries: CloudOsEntry[]): CloudOsEntry[] {
|
|
|
560
552
|
);
|
|
561
553
|
}
|
|
562
554
|
|
|
555
|
+
// ── 活动指示(本回合此刻在干什么)────────────────────────────────────────────
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* 底部活动指示器要显示的动画与文案。
|
|
559
|
+
*/
|
|
560
|
+
export interface CloudOsActivity {
|
|
561
|
+
state: OrbState;
|
|
562
|
+
label: string;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* 尾巴是否**自己会说话** —— 有它在动,就不该再挂一个活动指示器。
|
|
567
|
+
*
|
|
568
|
+
* 正在流的正文/思考、还在跑的工具组或计划,以及一张**还等着你作答**的
|
|
569
|
+
* `ask_user` 卡都算:前三者自身就有动效,最后一个等的是人不是模型,这时候
|
|
570
|
+
* 显示「Thinking」是在撒谎。
|
|
571
|
+
*/
|
|
572
|
+
function isLiveBlock(block: AssistantBlock): boolean {
|
|
573
|
+
switch (block.kind) {
|
|
574
|
+
case "text":
|
|
575
|
+
case "reasoning":
|
|
576
|
+
return true;
|
|
577
|
+
case "toolGroup":
|
|
578
|
+
return block.group.hasRunning;
|
|
579
|
+
case "plan":
|
|
580
|
+
return block.running;
|
|
581
|
+
case "askUser":
|
|
582
|
+
return block.pending;
|
|
583
|
+
// approval 块只在 awaitingApproval 时才建出来,所以它在场就是在等人。
|
|
584
|
+
case "approval":
|
|
585
|
+
return true;
|
|
586
|
+
case "parallel":
|
|
587
|
+
return block.tools.some((tool) => tool.status === "running");
|
|
588
|
+
case "subagents":
|
|
589
|
+
return block.agents.some((agent) => agent.status === "running");
|
|
590
|
+
default:
|
|
591
|
+
return false;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// 已结算的尾巴 → 此刻真正在发生的事。措辞刻意只说得出口的那部分:
|
|
596
|
+
// 工具跑完了,模型在读它的输出;卡答完了,模型在接你的答案。
|
|
597
|
+
function activityForTail(block: AssistantBlock | undefined): CloudOsActivity {
|
|
598
|
+
if (!block) return { state: "solving", label: "Thinking…" };
|
|
599
|
+
if (block.kind === "askUser") {
|
|
600
|
+
return { state: "solving", label: "Picking up your answer…" };
|
|
601
|
+
}
|
|
602
|
+
if (block.kind === "toolGroup") {
|
|
603
|
+
const kind = block.group.calls.at(-1)?.kind;
|
|
604
|
+
return kind !== undefined &&
|
|
605
|
+
["web-search", "web-fetch", "grep", "glob", "list", "read"].includes(
|
|
606
|
+
kind,
|
|
607
|
+
)
|
|
608
|
+
? { state: "searching", label: "Reading results…" }
|
|
609
|
+
: { state: "working", label: "Working…" };
|
|
610
|
+
}
|
|
611
|
+
return { state: "working", label: "Working…" };
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* 本回合此刻该显示什么活动;`null` = 尾巴自己会说话,别再叠一个。
|
|
616
|
+
*
|
|
617
|
+
* @remarks
|
|
618
|
+
* 判据是「**尾巴有没有活口**」,不是「本回合有没有出过内容」。后者曾经是这里的
|
|
619
|
+
* 写法,于是答完 `ask_user`(卡片变成 Submitted,读起来就是"结束了")到模型下一
|
|
620
|
+
* 个 chunk 之间整屏没有任何在跑的迹象 —— 而那段恰好是最长的一次 TTFT,看起来
|
|
621
|
+
* 就是卡死。凡是尾巴为**已结算卡片**的断档都是同一个洞,这里一并补上。
|
|
622
|
+
*/
|
|
623
|
+
export function deriveTurnActivity(
|
|
624
|
+
entries: readonly CloudOsEntry[],
|
|
625
|
+
options: { isActive: boolean; hasPendingSteer?: boolean },
|
|
626
|
+
): CloudOsActivity | null {
|
|
627
|
+
if (!options.isActive) return null;
|
|
628
|
+
const tail = entries.at(-1) ?? null;
|
|
629
|
+
const block = tail?.type === "assistant" ? tail.blocks.at(-1) : undefined;
|
|
630
|
+
if (block && isLiveBlock(block)) return null;
|
|
631
|
+
return options.hasPendingSteer
|
|
632
|
+
? { state: "solving", label: "Waiting for the current step to finish…" }
|
|
633
|
+
: activityForTail(block);
|
|
634
|
+
}
|
|
635
|
+
|
|
563
636
|
// ── 行间距节奏(照搬原 rhythmTopClass)──────────────────────────────────────
|
|
564
637
|
|
|
565
638
|
function isUserEntry(entry: CloudOsEntry): boolean {
|
|
@@ -20,6 +20,11 @@ import { DropdownMenu } from "../primitives/dropdown-menu";
|
|
|
20
20
|
import { Tooltip } from "../primitives/tooltip";
|
|
21
21
|
import { WorkshopIconButton } from "../primitives/workshop-controls";
|
|
22
22
|
|
|
23
|
+
const isComposingKeyEvent = (event: {
|
|
24
|
+
isComposing?: boolean;
|
|
25
|
+
keyCode?: number;
|
|
26
|
+
}) => event.isComposing === true || event.keyCode === 229;
|
|
27
|
+
|
|
23
28
|
/**
|
|
24
29
|
* 从 cloudflare-os-main `ChatInterface.tsx` 的 `ChatInput` 拷来。
|
|
25
30
|
*
|
|
@@ -303,8 +308,12 @@ export function CloudOsChatInput({
|
|
|
303
308
|
}
|
|
304
309
|
}}
|
|
305
310
|
onKeyDown={(event) => {
|
|
306
|
-
// Enter 发送(按住 Shift 换行)
|
|
307
|
-
if (
|
|
311
|
+
// Enter 发送(按住 Shift 换行); IME 组合中不发送
|
|
312
|
+
if (
|
|
313
|
+
event.key === "Enter" &&
|
|
314
|
+
!event.shiftKey &&
|
|
315
|
+
!isComposingKeyEvent(event.nativeEvent)
|
|
316
|
+
) {
|
|
308
317
|
event.preventDefault();
|
|
309
318
|
if (canSend) onSubmit();
|
|
310
319
|
}
|
package/cloud-os/index.ts
CHANGED
|
@@ -61,9 +61,12 @@ export {
|
|
|
61
61
|
} from "./chat/rich-blocks";
|
|
62
62
|
export type { ApprovalDecision } from "./chat/rich-blocks";
|
|
63
63
|
|
|
64
|
+
export { CloudOsActivityIndicator } from "./chat/activity-indicator";
|
|
65
|
+
|
|
64
66
|
export {
|
|
65
67
|
buildCloudOsEntries,
|
|
66
68
|
cloudOsMetadata,
|
|
69
|
+
deriveTurnActivity,
|
|
67
70
|
formatClockTime,
|
|
68
71
|
formatFullTimestamp,
|
|
69
72
|
messageText,
|
|
@@ -71,6 +74,7 @@ export {
|
|
|
71
74
|
} from "./chat/transcript-model";
|
|
72
75
|
export type {
|
|
73
76
|
AssistantBlock,
|
|
77
|
+
CloudOsActivity,
|
|
74
78
|
CloudOsAttachment,
|
|
75
79
|
CloudOsEntry,
|
|
76
80
|
ParallelTool,
|
|
@@ -24,11 +24,12 @@ const WORKSPACE_TRANSITION_MS = 200;
|
|
|
24
24
|
|
|
25
25
|
const isBrowser = typeof window !== "undefined";
|
|
26
26
|
|
|
27
|
-
function clampChatWidth(width: number) {
|
|
27
|
+
function clampChatWidth(width: number, containerWidth?: number) {
|
|
28
28
|
if (!isBrowser) {
|
|
29
29
|
return Math.max(MIN_CHAT_WIDTH, Math.min(DEFAULT_CHAT_WIDTH, width));
|
|
30
30
|
}
|
|
31
|
-
const
|
|
31
|
+
const available = containerWidth ?? window.innerWidth;
|
|
32
|
+
const max = Math.max(MIN_CHAT_WIDTH, available - MIN_WORKSPACE_WIDTH);
|
|
32
33
|
return Math.max(MIN_CHAT_WIDTH, Math.min(max, width));
|
|
33
34
|
}
|
|
34
35
|
|
|
@@ -48,6 +49,11 @@ function getInitialChatWidth() {
|
|
|
48
49
|
return clampChatWidth(Number.isFinite(parsed) ? parsed : fallback);
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
type ResizeSession = {
|
|
53
|
+
startX: number;
|
|
54
|
+
startWidth: number;
|
|
55
|
+
};
|
|
56
|
+
|
|
51
57
|
export interface CloudOsWorkspaceSplitProps {
|
|
52
58
|
chat: ReactNode;
|
|
53
59
|
workspace: ReactNode;
|
|
@@ -68,23 +74,40 @@ export function CloudOsWorkspaceSplit({
|
|
|
68
74
|
railWidth = 0,
|
|
69
75
|
rail,
|
|
70
76
|
}: CloudOsWorkspaceSplitProps) {
|
|
77
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
78
|
+
const resizeSessionRef = useRef<ResizeSession | null>(null);
|
|
71
79
|
const [chatWidth, setChatWidth] = useState(getInitialChatWidth);
|
|
72
80
|
const [isResizing, setIsResizing] = useState(false);
|
|
73
81
|
const [transitionEnabled, setTransitionEnabled] = useState(false);
|
|
74
82
|
const chatWidthRef = useRef(chatWidth);
|
|
75
83
|
chatWidthRef.current = chatWidth;
|
|
76
84
|
|
|
85
|
+
const getContainerWidth = useCallback(
|
|
86
|
+
() => containerRef.current?.getBoundingClientRect().width,
|
|
87
|
+
[],
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const clampToContainer = useCallback(
|
|
91
|
+
(width: number) => clampChatWidth(width, getContainerWidth()),
|
|
92
|
+
[getContainerWidth],
|
|
93
|
+
);
|
|
94
|
+
|
|
77
95
|
// 首帧不要动画:否则每次挂载都会看到面板「滑进来」。
|
|
78
96
|
useEffect(() => {
|
|
79
97
|
const timer = window.setTimeout(() => setTransitionEnabled(true), 0);
|
|
80
98
|
return () => window.clearTimeout(timer);
|
|
81
99
|
}, []);
|
|
82
100
|
|
|
101
|
+
// 挂载后按真实容器宽度重新 clamp 一次(宿主可能有左侧 sidebar 等偏移)。
|
|
83
102
|
useEffect(() => {
|
|
84
|
-
|
|
103
|
+
setChatWidth((width) => clampToContainer(width));
|
|
104
|
+
}, [clampToContainer]);
|
|
105
|
+
|
|
106
|
+
useEffect(() => {
|
|
107
|
+
const onResize = () => setChatWidth((width) => clampToContainer(width));
|
|
85
108
|
window.addEventListener("resize", onResize);
|
|
86
109
|
return () => window.removeEventListener("resize", onResize);
|
|
87
|
-
}, []);
|
|
110
|
+
}, [clampToContainer]);
|
|
88
111
|
|
|
89
112
|
const persistChatWidth = useCallback((width: number) => {
|
|
90
113
|
try {
|
|
@@ -94,11 +117,24 @@ export function CloudOsWorkspaceSplit({
|
|
|
94
117
|
}
|
|
95
118
|
}, []);
|
|
96
119
|
|
|
120
|
+
const widthFromPointer = useCallback(
|
|
121
|
+
(clientX: number) => {
|
|
122
|
+
const session = resizeSessionRef.current;
|
|
123
|
+
if (!session) return chatWidthRef.current;
|
|
124
|
+
return clampToContainer(session.startWidth + clientX - session.startX);
|
|
125
|
+
},
|
|
126
|
+
[clampToContainer],
|
|
127
|
+
);
|
|
128
|
+
|
|
97
129
|
// 用 pointer capture:拖过右侧 iframe 时事件也不会丢。
|
|
98
130
|
const handleResizePointerDown = useCallback(
|
|
99
131
|
(event: ReactPointerEvent<HTMLDivElement>) => {
|
|
100
132
|
if (!workspaceOpen) return;
|
|
101
133
|
event.preventDefault();
|
|
134
|
+
resizeSessionRef.current = {
|
|
135
|
+
startX: event.clientX,
|
|
136
|
+
startWidth: chatWidthRef.current,
|
|
137
|
+
};
|
|
102
138
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
103
139
|
setIsResizing(true);
|
|
104
140
|
},
|
|
@@ -107,9 +143,9 @@ export function CloudOsWorkspaceSplit({
|
|
|
107
143
|
const handleResizePointerMove = useCallback(
|
|
108
144
|
(event: ReactPointerEvent<HTMLDivElement>) => {
|
|
109
145
|
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
|
|
110
|
-
setChatWidth(
|
|
146
|
+
setChatWidth(widthFromPointer(event.clientX));
|
|
111
147
|
},
|
|
112
|
-
[],
|
|
148
|
+
[widthFromPointer],
|
|
113
149
|
);
|
|
114
150
|
const handleResizePointerUp = useCallback(
|
|
115
151
|
(event: ReactPointerEvent<HTMLDivElement>) => {
|
|
@@ -119,12 +155,13 @@ export function CloudOsWorkspaceSplit({
|
|
|
119
155
|
const width =
|
|
120
156
|
event.type === "pointercancel"
|
|
121
157
|
? chatWidthRef.current
|
|
122
|
-
:
|
|
158
|
+
: widthFromPointer(event.clientX);
|
|
159
|
+
resizeSessionRef.current = null;
|
|
123
160
|
setChatWidth(width);
|
|
124
161
|
persistChatWidth(width);
|
|
125
162
|
setIsResizing(false);
|
|
126
163
|
},
|
|
127
|
-
[persistChatWidth],
|
|
164
|
+
[persistChatWidth, widthFromPointer],
|
|
128
165
|
);
|
|
129
166
|
|
|
130
167
|
useEffect(() => {
|
|
@@ -147,7 +184,10 @@ export function CloudOsWorkspaceSplit({
|
|
|
147
184
|
return (
|
|
148
185
|
// h-full 而不是只靠 flex-1:宿主给的容器不一定是 flex,那样 flex-1 不生效,
|
|
149
186
|
// 整个分栏会塌成内容高度(聊天区不铺满、输入框吊在半空)。
|
|
150
|
-
<div
|
|
187
|
+
<div
|
|
188
|
+
ref={containerRef}
|
|
189
|
+
className="relative flex h-full min-h-0 flex-1 overflow-hidden bg-kumo-base"
|
|
190
|
+
>
|
|
151
191
|
{isAgentActive && (
|
|
152
192
|
<div
|
|
153
193
|
className="absolute left-0 z-10 h-0"
|
|
@@ -505,6 +505,20 @@
|
|
|
505
505
|
}
|
|
506
506
|
}
|
|
507
507
|
|
|
508
|
+
/* ── 活动指示:回合在跑但屏幕上没东西在动时 ──────────────────────────────── */
|
|
509
|
+
.cos-activity {
|
|
510
|
+
display: flex;
|
|
511
|
+
align-items: center;
|
|
512
|
+
gap: 8px;
|
|
513
|
+
min-height: 20px;
|
|
514
|
+
color: var(--text-color-kumo-subtle);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/* 球是固定 20px 的画布,不许被 flex 压扁。 */
|
|
518
|
+
.cos-activity canvas {
|
|
519
|
+
flex: none;
|
|
520
|
+
}
|
|
521
|
+
|
|
508
522
|
/* ── 流式占位:Thinking 扫光 ──────────────────────────────────────────────── */
|
|
509
523
|
.cos-thinking-shimmer {
|
|
510
524
|
color: var(--text-color-kumo-inactive);
|
|
@@ -376,20 +376,21 @@ export function CloudOsChatShowcase() {
|
|
|
376
376
|
setValue("");
|
|
377
377
|
setLastEvent(`已发送后续问题:${text}`);
|
|
378
378
|
}}
|
|
379
|
-
|
|
379
|
+
onRespond={async (_toolCallId, response) => {
|
|
380
380
|
const data =
|
|
381
|
-
|
|
382
|
-
? (
|
|
381
|
+
response && typeof response === "object"
|
|
382
|
+
? (response as { selections?: string[]; text?: string })
|
|
383
383
|
: {};
|
|
384
384
|
const answer = [
|
|
385
|
-
...(data.selections
|
|
385
|
+
...(data.selections ?? []),
|
|
386
386
|
data.text?.trim() ?? "",
|
|
387
387
|
]
|
|
388
388
|
.filter(Boolean)
|
|
389
|
-
.join("
|
|
389
|
+
.join(" / ");
|
|
390
390
|
if (answer) setHumanAnswer(answer);
|
|
391
391
|
selectScenario("planning");
|
|
392
392
|
setLastEvent(`已回答并继续:${answer || humanAnswer}`);
|
|
393
|
+
return true;
|
|
393
394
|
}}
|
|
394
395
|
/>
|
|
395
396
|
|
package/package.json
CHANGED
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
// camel 是 dev-only 的展示变体(App.tsx 的 `/__camel-chat` 路由),没有接生产宿主。
|
|
2
|
+
// 它的交互卡片仍用旧的 `onAnswer(payload)` 回调,**没有**迁到 client-settled tool 的
|
|
3
|
+
// `onRespond(toolCallId, response)` —— 生产聊天面板走的是 cloud-os,已经迁完。
|
|
4
|
+
// 哪天 camel 要上生产,这里必须一起改,否则卡片点了不会把答案回写给那次工具调用。
|
|
5
|
+
|
|
1
6
|
import type { UIMessage } from "ai";
|
|
2
7
|
import {
|
|
3
8
|
ArrowDownIcon,
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
type ReactNode,
|
|
40
40
|
} from "react";
|
|
41
41
|
import { ComposerTriggerPopover } from "../composer/composer-trigger-popover";
|
|
42
|
+
import { isComposingKeyEvent } from "../composer/key-rules";
|
|
42
43
|
import type { ComposerCommand } from "../composer/types";
|
|
43
44
|
import { cn } from "../internal/cn";
|
|
44
45
|
|
|
@@ -345,7 +346,7 @@ function CamelPromptInputContent({
|
|
|
345
346
|
if (
|
|
346
347
|
event.key === "Enter" &&
|
|
347
348
|
!event.shiftKey &&
|
|
348
|
-
!event.nativeEvent
|
|
349
|
+
!isComposingKeyEvent(event.nativeEvent)
|
|
349
350
|
) {
|
|
350
351
|
event.preventDefault();
|
|
351
352
|
submit();
|
|
@@ -103,7 +103,7 @@ export function ChatSummaryPanel({
|
|
|
103
103
|
return (
|
|
104
104
|
<aside
|
|
105
105
|
className={cn(
|
|
106
|
-
"w-[19rem] min-w-0 max-w-
|
|
106
|
+
"absolute right-3 top-11 z-30 w-[19rem] min-w-0 max-w-[calc(100%-1.5rem)] overflow-hidden rounded-2xl border border-border bg-popover shadow-card",
|
|
107
107
|
"animate-in fade-in-0 zoom-in-95 slide-in-from-top-2 duration-200 motion-reduce:animate-none",
|
|
108
108
|
className,
|
|
109
109
|
)}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type KeyboardEvent,
|
|
11
11
|
} from "react";
|
|
12
12
|
import { cn } from "../internal/cn";
|
|
13
|
+
import { isComposingKeyEvent } from "./key-rules";
|
|
13
14
|
import { Composer } from "./composer";
|
|
14
15
|
import type {
|
|
15
16
|
ChatComposerProps,
|
|
@@ -152,7 +153,7 @@ function ChatComposerInner<TAttachment>(
|
|
|
152
153
|
if (
|
|
153
154
|
(event.key === "Enter" &&
|
|
154
155
|
!event.shiftKey &&
|
|
155
|
-
!event.nativeEvent
|
|
156
|
+
!isComposingKeyEvent(event.nativeEvent)) ||
|
|
156
157
|
event.key === "Tab"
|
|
157
158
|
) {
|
|
158
159
|
const command = filtered[active] ?? filtered[0];
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
Attachments,
|
|
27
27
|
} from "./composer-attachments";
|
|
28
28
|
import { useComposerDrop } from "./drop-controller";
|
|
29
|
+
import { isComposingKeyEvent } from "./key-rules";
|
|
29
30
|
import type {
|
|
30
31
|
ComposerAttachmentView,
|
|
31
32
|
ComposerProps,
|
|
@@ -247,7 +248,7 @@ function ComposerInner<TAttachment>(
|
|
|
247
248
|
if (
|
|
248
249
|
event.key === "Enter" &&
|
|
249
250
|
!event.shiftKey &&
|
|
250
|
-
!event.nativeEvent
|
|
251
|
+
!isComposingKeyEvent(event.nativeEvent)
|
|
251
252
|
) {
|
|
252
253
|
event.preventDefault();
|
|
253
254
|
if (canSend) void submit();
|
package/src/composer/index.ts
CHANGED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether Enter belongs to an input method rather than to the Composer.
|
|
3
|
+
*
|
|
4
|
+
* Both signals are honoured on their own: some Chinese input methods report only the
|
|
5
|
+
* legacy `keyCode === 229`, and either one alone means a word is still being composed.
|
|
6
|
+
*/
|
|
7
|
+
export function isComposingKeyEvent(event: {
|
|
8
|
+
isComposing?: boolean;
|
|
9
|
+
keyCode?: number;
|
|
10
|
+
}): boolean {
|
|
11
|
+
return event.isComposing === true || event.keyCode === 229;
|
|
12
|
+
}
|
package/src/styles/index.css
CHANGED
|
@@ -2128,7 +2128,10 @@
|
|
|
2128
2128
|
min-height: 120px;
|
|
2129
2129
|
gap: 0;
|
|
2130
2130
|
border-radius: 16px;
|
|
2131
|
-
|
|
2131
|
+
/* 这里曾写死 #1c1c1a,于是 light 档下输入框还是近黑的。
|
|
2132
|
+
表面色一律走 token:--mp-card → --card(light #fff / dark #1c1c1c)。
|
|
2133
|
+
本节其余几个写死色是状态/图标强调色(琥珀、蓝),两档背景上都读得出来,不在此列。 */
|
|
2134
|
+
background: var(--mp-card);
|
|
2132
2135
|
box-shadow: none;
|
|
2133
2136
|
}
|
|
2134
2137
|
|