@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
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
/**
|
|
11
11
|
* 左聊天 / 右工作区的双栏骨架。从 cloudflare-os-main `GadgetEditor.tsx` 的 BODY
|
|
12
12
|
* 段拷来:同一条 1px 的 kumo-line 分隔即拖拽把手、同一套 pointer capture 拖拽
|
|
13
|
-
* (拖过 iframe 也不丢)、同一个 200ms
|
|
13
|
+
* (拖过 iframe 也不丢)、同一个 200ms 的收起/展开过渡。
|
|
14
14
|
*
|
|
15
15
|
* 适配点:原文件把左右两侧的内容写死成 ChatInterface / GadgetUI,这里换成
|
|
16
16
|
* children 插槽;chatWidth 的 localStorage key 换成 cloud-os 自己的。
|
|
@@ -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,13 +49,16 @@ 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;
|
|
54
60
|
/** 关掉右栏时聊天占满整宽。 */
|
|
55
61
|
workspaceOpen: boolean;
|
|
56
|
-
/** 顶部那条推进条:回合进行中显示。 */
|
|
57
|
-
isAgentActive?: boolean;
|
|
58
62
|
/** 右栏收起时依然保留的窄边栏宽度(原文件的 Outputs rail)。 */
|
|
59
63
|
railWidth?: number;
|
|
60
64
|
rail?: ReactNode;
|
|
@@ -64,27 +68,43 @@ export function CloudOsWorkspaceSplit({
|
|
|
64
68
|
chat,
|
|
65
69
|
workspace,
|
|
66
70
|
workspaceOpen,
|
|
67
|
-
isAgentActive = false,
|
|
68
71
|
railWidth = 0,
|
|
69
72
|
rail,
|
|
70
73
|
}: CloudOsWorkspaceSplitProps) {
|
|
74
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
75
|
+
const resizeSessionRef = useRef<ResizeSession | null>(null);
|
|
71
76
|
const [chatWidth, setChatWidth] = useState(getInitialChatWidth);
|
|
72
77
|
const [isResizing, setIsResizing] = useState(false);
|
|
73
78
|
const [transitionEnabled, setTransitionEnabled] = useState(false);
|
|
74
79
|
const chatWidthRef = useRef(chatWidth);
|
|
75
80
|
chatWidthRef.current = chatWidth;
|
|
76
81
|
|
|
82
|
+
const getContainerWidth = useCallback(
|
|
83
|
+
() => containerRef.current?.getBoundingClientRect().width,
|
|
84
|
+
[],
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const clampToContainer = useCallback(
|
|
88
|
+
(width: number) => clampChatWidth(width, getContainerWidth()),
|
|
89
|
+
[getContainerWidth],
|
|
90
|
+
);
|
|
91
|
+
|
|
77
92
|
// 首帧不要动画:否则每次挂载都会看到面板「滑进来」。
|
|
78
93
|
useEffect(() => {
|
|
79
94
|
const timer = window.setTimeout(() => setTransitionEnabled(true), 0);
|
|
80
95
|
return () => window.clearTimeout(timer);
|
|
81
96
|
}, []);
|
|
82
97
|
|
|
98
|
+
// 挂载后按真实容器宽度重新 clamp 一次(宿主可能有左侧 sidebar 等偏移)。
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
setChatWidth((width) => clampToContainer(width));
|
|
101
|
+
}, [clampToContainer]);
|
|
102
|
+
|
|
83
103
|
useEffect(() => {
|
|
84
|
-
const onResize = () => setChatWidth((width) =>
|
|
104
|
+
const onResize = () => setChatWidth((width) => clampToContainer(width));
|
|
85
105
|
window.addEventListener("resize", onResize);
|
|
86
106
|
return () => window.removeEventListener("resize", onResize);
|
|
87
|
-
}, []);
|
|
107
|
+
}, [clampToContainer]);
|
|
88
108
|
|
|
89
109
|
const persistChatWidth = useCallback((width: number) => {
|
|
90
110
|
try {
|
|
@@ -94,11 +114,24 @@ export function CloudOsWorkspaceSplit({
|
|
|
94
114
|
}
|
|
95
115
|
}, []);
|
|
96
116
|
|
|
117
|
+
const widthFromPointer = useCallback(
|
|
118
|
+
(clientX: number) => {
|
|
119
|
+
const session = resizeSessionRef.current;
|
|
120
|
+
if (!session) return chatWidthRef.current;
|
|
121
|
+
return clampToContainer(session.startWidth + clientX - session.startX);
|
|
122
|
+
},
|
|
123
|
+
[clampToContainer],
|
|
124
|
+
);
|
|
125
|
+
|
|
97
126
|
// 用 pointer capture:拖过右侧 iframe 时事件也不会丢。
|
|
98
127
|
const handleResizePointerDown = useCallback(
|
|
99
128
|
(event: ReactPointerEvent<HTMLDivElement>) => {
|
|
100
129
|
if (!workspaceOpen) return;
|
|
101
130
|
event.preventDefault();
|
|
131
|
+
resizeSessionRef.current = {
|
|
132
|
+
startX: event.clientX,
|
|
133
|
+
startWidth: chatWidthRef.current,
|
|
134
|
+
};
|
|
102
135
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
103
136
|
setIsResizing(true);
|
|
104
137
|
},
|
|
@@ -107,9 +140,9 @@ export function CloudOsWorkspaceSplit({
|
|
|
107
140
|
const handleResizePointerMove = useCallback(
|
|
108
141
|
(event: ReactPointerEvent<HTMLDivElement>) => {
|
|
109
142
|
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
|
|
110
|
-
setChatWidth(
|
|
143
|
+
setChatWidth(widthFromPointer(event.clientX));
|
|
111
144
|
},
|
|
112
|
-
[],
|
|
145
|
+
[widthFromPointer],
|
|
113
146
|
);
|
|
114
147
|
const handleResizePointerUp = useCallback(
|
|
115
148
|
(event: ReactPointerEvent<HTMLDivElement>) => {
|
|
@@ -119,12 +152,13 @@ export function CloudOsWorkspaceSplit({
|
|
|
119
152
|
const width =
|
|
120
153
|
event.type === "pointercancel"
|
|
121
154
|
? chatWidthRef.current
|
|
122
|
-
:
|
|
155
|
+
: widthFromPointer(event.clientX);
|
|
156
|
+
resizeSessionRef.current = null;
|
|
123
157
|
setChatWidth(width);
|
|
124
158
|
persistChatWidth(width);
|
|
125
159
|
setIsResizing(false);
|
|
126
160
|
},
|
|
127
|
-
[persistChatWidth],
|
|
161
|
+
[persistChatWidth, widthFromPointer],
|
|
128
162
|
);
|
|
129
163
|
|
|
130
164
|
useEffect(() => {
|
|
@@ -147,18 +181,10 @@ export function CloudOsWorkspaceSplit({
|
|
|
147
181
|
return (
|
|
148
182
|
// h-full 而不是只靠 flex-1:宿主给的容器不一定是 flex,那样 flex-1 不生效,
|
|
149
183
|
// 整个分栏会塌成内容高度(聊天区不铺满、输入框吊在半空)。
|
|
150
|
-
<div
|
|
151
|
-
{
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
style={{ top: 0, right: railWidth }}
|
|
155
|
-
>
|
|
156
|
-
<div className="absolute left-0 right-0 h-0.5 overflow-hidden bg-kumo-fill">
|
|
157
|
-
<div className="cos-progress-sweep absolute inset-y-0 w-1/3 bg-kumo-brand" />
|
|
158
|
-
</div>
|
|
159
|
-
</div>
|
|
160
|
-
)}
|
|
161
|
-
|
|
184
|
+
<div
|
|
185
|
+
ref={containerRef}
|
|
186
|
+
className="relative flex h-full min-h-0 flex-1 overflow-hidden bg-kumo-base"
|
|
187
|
+
>
|
|
162
188
|
{/* ── 左:聊天 ──────────────────────────────────────────────────────── */}
|
|
163
189
|
<div
|
|
164
190
|
className={`flex h-full min-h-0 flex-shrink-0 flex-col ${transitionClass} ${
|
|
@@ -356,23 +356,6 @@
|
|
|
356
356
|
background: transparent;
|
|
357
357
|
}
|
|
358
358
|
|
|
359
|
-
/* ── 顶部推进条(isAgentActive)──────────────────────────────────────────── */
|
|
360
|
-
@keyframes cos-thinking {
|
|
361
|
-
0% {
|
|
362
|
-
left: -33%;
|
|
363
|
-
}
|
|
364
|
-
50% {
|
|
365
|
-
left: 100%;
|
|
366
|
-
}
|
|
367
|
-
100% {
|
|
368
|
-
left: -33%;
|
|
369
|
-
}
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
.cos-progress-sweep {
|
|
373
|
-
animation: cos-thinking 1.5s ease-in-out infinite;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
359
|
/* ============================================================================
|
|
377
360
|
* 以下整段来自 ChatInterface.module.css —— CSS Module 类名改成 cos- 全局前缀
|
|
378
361
|
* ========================================================================== */
|
|
@@ -505,6 +488,20 @@
|
|
|
505
488
|
}
|
|
506
489
|
}
|
|
507
490
|
|
|
491
|
+
/* ── 活动指示:回合在跑但屏幕上没东西在动时 ──────────────────────────────── */
|
|
492
|
+
.cos-activity {
|
|
493
|
+
display: flex;
|
|
494
|
+
align-items: center;
|
|
495
|
+
gap: 8px;
|
|
496
|
+
min-height: 20px;
|
|
497
|
+
color: var(--text-color-kumo-subtle);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/* 球是固定 20px 的画布,不许被 flex 压扁。 */
|
|
501
|
+
.cos-activity canvas {
|
|
502
|
+
flex: none;
|
|
503
|
+
}
|
|
504
|
+
|
|
508
505
|
/* ── 流式占位:Thinking 扫光 ──────────────────────────────────────────────── */
|
|
509
506
|
.cos-thinking-shimmer {
|
|
510
507
|
color: var(--text-color-kumo-inactive);
|
|
@@ -536,9 +533,6 @@
|
|
|
536
533
|
background: none;
|
|
537
534
|
-webkit-text-fill-color: currentColor;
|
|
538
535
|
}
|
|
539
|
-
.cos-progress-sweep {
|
|
540
|
-
animation: none;
|
|
541
|
-
}
|
|
542
536
|
}
|
|
543
537
|
|
|
544
538
|
/* ── 观察类 markdown(行内、紧凑)────────────────────────────────────────── */
|
|
@@ -283,7 +283,6 @@ export function CloudOsChatShowcase() {
|
|
|
283
283
|
|
|
284
284
|
<CloudOsWorkspaceSplit
|
|
285
285
|
workspaceOpen={workspaceOpen}
|
|
286
|
-
isAgentActive={status === "submitted" || status === "streaming"}
|
|
287
286
|
chat={
|
|
288
287
|
<div className="flex h-full min-h-0 flex-col bg-kumo-base">
|
|
289
288
|
<aside
|
|
@@ -376,20 +375,21 @@ export function CloudOsChatShowcase() {
|
|
|
376
375
|
setValue("");
|
|
377
376
|
setLastEvent(`已发送后续问题:${text}`);
|
|
378
377
|
}}
|
|
379
|
-
|
|
378
|
+
onRespond={async (_toolCallId, response) => {
|
|
380
379
|
const data =
|
|
381
|
-
|
|
382
|
-
? (
|
|
380
|
+
response && typeof response === "object"
|
|
381
|
+
? (response as { selections?: string[]; text?: string })
|
|
383
382
|
: {};
|
|
384
383
|
const answer = [
|
|
385
|
-
...(data.selections
|
|
384
|
+
...(data.selections ?? []),
|
|
386
385
|
data.text?.trim() ?? "",
|
|
387
386
|
]
|
|
388
387
|
.filter(Boolean)
|
|
389
|
-
.join("
|
|
388
|
+
.join(" / ");
|
|
390
389
|
if (answer) setHumanAnswer(answer);
|
|
391
390
|
selectScenario("planning");
|
|
392
391
|
setLastEvent(`已回答并继续:${answer || humanAnswer}`);
|
|
392
|
+
return true;
|
|
393
393
|
}}
|
|
394
394
|
/>
|
|
395
395
|
|
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];
|
|
@@ -156,7 +156,7 @@ function Items({
|
|
|
156
156
|
{item.label}
|
|
157
157
|
</span>
|
|
158
158
|
{item.description && (
|
|
159
|
-
<span className="ms-5.5 text-xs leading-tight text-muted-foreground">
|
|
159
|
+
<span className="ms-5.5 w-full truncate text-xs leading-tight text-muted-foreground">
|
|
160
160
|
{item.description}
|
|
161
161
|
</span>
|
|
162
162
|
)}
|
|
@@ -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
|
|