@springbrand/message-panel 0.1.3-alpha.0 → 0.1.3-alpha.10
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 +71 -0
- 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 +660 -0
- package/cloud-os/chat/markdown-message.tsx +78 -0
- package/cloud-os/chat/rich-blocks.tsx +787 -0
- package/cloud-os/chat/tool-presentation.ts +586 -0
- package/cloud-os/chat/tool-rows.tsx +216 -0
- package/cloud-os/chat/transcript-model.ts +780 -0
- package/cloud-os/composer/cloud-os-chat-input.tsx +686 -0
- package/cloud-os/file-view.tsx +258 -0
- package/cloud-os/index.ts +123 -0
- package/cloud-os/internal/cn.ts +6 -0
- package/cloud-os/internal/theme-context.tsx +16 -0
- package/cloud-os/layout/cloud-os-root.tsx +34 -0
- package/cloud-os/layout/cloud-os-workspace-split.tsx +228 -0
- package/cloud-os/primitives/dropdown-menu.tsx +82 -0
- package/cloud-os/primitives/tooltip.tsx +68 -0
- package/cloud-os/primitives/workshop-controls.tsx +114 -0
- package/cloud-os/styles/cloud-os.css +553 -0
- package/cloud-os/workspace/cloud-os-workspace-panel.tsx +272 -0
- package/demo/camel-chat-showcase.tsx +13 -917
- package/demo/chat-scenarios.ts +926 -0
- package/demo/cloud-os-chat-showcase.tsx +481 -0
- package/demo/index.ts +2 -0
- package/package.json +17 -5
- package/src/camel/camel-chat-messages.tsx +55 -20
- package/src/camel/camel-prompt-input.tsx +2 -1
- package/src/camel/camel-turn.ts +21 -3
- 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/message.tsx +0 -8
- package/src/parts/plan.ts +3 -2
- package/src/styles/index.css +4 -1
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import { File as FileIcon } from "@phosphor-icons/react";
|
|
2
|
+
import {
|
|
3
|
+
CheckIcon,
|
|
4
|
+
FileArchiveIcon,
|
|
5
|
+
FileImageIcon,
|
|
6
|
+
FileTextIcon,
|
|
7
|
+
Loader2Icon,
|
|
8
|
+
RotateCcwIcon,
|
|
9
|
+
XIcon,
|
|
10
|
+
} from "lucide-react";
|
|
11
|
+
import {
|
|
12
|
+
useEffect,
|
|
13
|
+
useRef,
|
|
14
|
+
useState,
|
|
15
|
+
type ComponentType,
|
|
16
|
+
} from "react";
|
|
17
|
+
|
|
18
|
+
export type FileViewPlacement =
|
|
19
|
+
| "assistant-message"
|
|
20
|
+
| "composer"
|
|
21
|
+
| "user-message";
|
|
22
|
+
|
|
23
|
+
export type FileViewStatus = "error" | "ready" | "uploading";
|
|
24
|
+
|
|
25
|
+
export interface FileViewFile {
|
|
26
|
+
url?: string;
|
|
27
|
+
filename?: string;
|
|
28
|
+
mediaType?: string;
|
|
29
|
+
size?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface FileViewProps {
|
|
33
|
+
file: FileViewFile;
|
|
34
|
+
placement: FileViewPlacement;
|
|
35
|
+
status?: FileViewStatus;
|
|
36
|
+
progress?: number;
|
|
37
|
+
onRemove?: () => void;
|
|
38
|
+
onRetry?: () => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export type FileRenderer = ComponentType<FileViewProps>;
|
|
42
|
+
|
|
43
|
+
function fileMeta(file: FileViewFile): string {
|
|
44
|
+
if (file.size === undefined) return "Ready";
|
|
45
|
+
if (file.size < 1024) return `${file.size} B`;
|
|
46
|
+
if (file.size < 1024 * 1024) return `${Math.round(file.size / 1024)} KB`;
|
|
47
|
+
return `${(file.size / 1024 / 1024).toFixed(1)} MB`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function attachmentIcon(file: FileViewFile) {
|
|
51
|
+
if (file.mediaType?.startsWith("image/")) return FileImageIcon;
|
|
52
|
+
if (/zip|archive|compressed/i.test(file.mediaType ?? "")) {
|
|
53
|
+
return FileArchiveIcon;
|
|
54
|
+
}
|
|
55
|
+
return FileTextIcon;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function ImageFileView({
|
|
59
|
+
src,
|
|
60
|
+
label,
|
|
61
|
+
placement,
|
|
62
|
+
}: {
|
|
63
|
+
src: string;
|
|
64
|
+
label: string;
|
|
65
|
+
placement: Exclude<FileViewPlacement, "composer">;
|
|
66
|
+
}) {
|
|
67
|
+
const imageRef = useRef<HTMLImageElement>(null);
|
|
68
|
+
const [loadedSrc, setLoadedSrc] = useState<string>();
|
|
69
|
+
const [errorSrc, setErrorSrc] = useState<string>();
|
|
70
|
+
const loaded = loadedSrc === src;
|
|
71
|
+
const error = errorSrc === src;
|
|
72
|
+
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
const image = imageRef.current;
|
|
75
|
+
if (!image?.complete) return;
|
|
76
|
+
if (image.naturalWidth > 0) setLoadedSrc(src);
|
|
77
|
+
else setErrorSrc(src);
|
|
78
|
+
}, [src]);
|
|
79
|
+
|
|
80
|
+
const assistant = placement === "assistant-message";
|
|
81
|
+
const previewClassName = assistant
|
|
82
|
+
? "relative inline-block max-w-full overflow-hidden rounded-xl border border-kumo-line align-bottom"
|
|
83
|
+
: "themed-thumbnail-shadow relative inline-block max-w-64 overflow-hidden rounded-xl border border-kumo-line align-bottom";
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<span
|
|
87
|
+
className={`${previewClassName} ${loaded && !error ? "" : "min-h-24 min-w-36"}`}
|
|
88
|
+
data-file-view=""
|
|
89
|
+
data-placement={placement}
|
|
90
|
+
>
|
|
91
|
+
<img
|
|
92
|
+
ref={imageRef}
|
|
93
|
+
src={src}
|
|
94
|
+
alt={label}
|
|
95
|
+
className={`${
|
|
96
|
+
assistant
|
|
97
|
+
? "block max-h-[28rem] max-w-full object-contain"
|
|
98
|
+
: "block max-h-52 max-w-64 object-cover"
|
|
99
|
+
} ${loaded && !error ? "" : "invisible"}`}
|
|
100
|
+
decoding="async"
|
|
101
|
+
loading="lazy"
|
|
102
|
+
onLoad={() => {
|
|
103
|
+
setLoadedSrc(src);
|
|
104
|
+
setErrorSrc(undefined);
|
|
105
|
+
}}
|
|
106
|
+
onError={() => setErrorSrc(src)}
|
|
107
|
+
/>
|
|
108
|
+
{!loaded && !error && (
|
|
109
|
+
<span
|
|
110
|
+
className="absolute inset-0 flex min-h-24 min-w-36 items-center justify-center bg-kumo-elevated text-kumo-inactive"
|
|
111
|
+
data-image-state="loading"
|
|
112
|
+
role="status"
|
|
113
|
+
>
|
|
114
|
+
<FileImageIcon
|
|
115
|
+
className="size-6 animate-pulse motion-reduce:animate-none"
|
|
116
|
+
aria-hidden="true"
|
|
117
|
+
/>
|
|
118
|
+
<span className="sr-only">Loading {label}</span>
|
|
119
|
+
</span>
|
|
120
|
+
)}
|
|
121
|
+
{error && (
|
|
122
|
+
<span
|
|
123
|
+
className="absolute inset-0 flex min-h-24 min-w-36 items-center justify-center bg-kumo-elevated text-kumo-danger"
|
|
124
|
+
data-image-state="error"
|
|
125
|
+
role="img"
|
|
126
|
+
aria-label={`Unable to load ${label}`}
|
|
127
|
+
>
|
|
128
|
+
<FileImageIcon className="size-6" aria-hidden="true" />
|
|
129
|
+
</span>
|
|
130
|
+
)}
|
|
131
|
+
</span>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Cloud OS 的默认文件视图。宿主可通过 `renderFile` 在同一 seam 替换它。
|
|
137
|
+
*/
|
|
138
|
+
export function FileView({
|
|
139
|
+
file,
|
|
140
|
+
placement,
|
|
141
|
+
status = "ready",
|
|
142
|
+
progress = 0,
|
|
143
|
+
onRemove,
|
|
144
|
+
onRetry,
|
|
145
|
+
}: FileViewProps) {
|
|
146
|
+
const isImage = Boolean(file.url && file.mediaType?.startsWith("image/"));
|
|
147
|
+
const label = file.filename ?? "Attachment";
|
|
148
|
+
|
|
149
|
+
if (placement === "composer") {
|
|
150
|
+
const AttachmentIcon = attachmentIcon(file);
|
|
151
|
+
const meta =
|
|
152
|
+
status === "uploading"
|
|
153
|
+
? "Uploading"
|
|
154
|
+
: status === "error"
|
|
155
|
+
? "Upload failed"
|
|
156
|
+
: fileMeta(file);
|
|
157
|
+
|
|
158
|
+
return (
|
|
159
|
+
<div
|
|
160
|
+
className="relative flex items-center gap-2.5 overflow-hidden rounded-[14px] bg-kumo-elevated py-1.5 ps-1.5 pe-2.5"
|
|
161
|
+
data-file-view=""
|
|
162
|
+
data-placement={placement}
|
|
163
|
+
data-status={status}
|
|
164
|
+
data-slot="composer-attachment"
|
|
165
|
+
data-state={status === "ready" ? "done" : status}
|
|
166
|
+
>
|
|
167
|
+
<span className="flex size-8 shrink-0 items-center justify-center rounded-[10px] bg-kumo-control text-kumo-inactive themed-thumbnail-shadow">
|
|
168
|
+
<AttachmentIcon className="size-4" aria-hidden="true" />
|
|
169
|
+
</span>
|
|
170
|
+
<span className="flex min-w-0 flex-col">
|
|
171
|
+
<span className="max-w-36 truncate text-xs font-medium text-kumo-default">
|
|
172
|
+
{label}
|
|
173
|
+
</span>
|
|
174
|
+
<span
|
|
175
|
+
className={
|
|
176
|
+
status === "error"
|
|
177
|
+
? "text-[11px] text-kumo-danger"
|
|
178
|
+
: "text-[11px] text-kumo-inactive"
|
|
179
|
+
}
|
|
180
|
+
>
|
|
181
|
+
{meta}
|
|
182
|
+
</span>
|
|
183
|
+
</span>
|
|
184
|
+
<span className="ms-1 flex w-5 items-center justify-end">
|
|
185
|
+
{status === "uploading" ? (
|
|
186
|
+
<Loader2Icon
|
|
187
|
+
className="size-3.5 animate-spin text-kumo-inactive motion-reduce:animate-none"
|
|
188
|
+
aria-label="Uploading"
|
|
189
|
+
/>
|
|
190
|
+
) : status === "error" && onRetry ? (
|
|
191
|
+
<button
|
|
192
|
+
type="button"
|
|
193
|
+
aria-label={`Retry ${label}`}
|
|
194
|
+
onClick={onRetry}
|
|
195
|
+
className="grid size-5 place-items-center rounded-full text-kumo-danger hover:bg-kumo-tint focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-kumo-brand/40"
|
|
196
|
+
>
|
|
197
|
+
<RotateCcwIcon className="size-3" aria-hidden="true" />
|
|
198
|
+
</button>
|
|
199
|
+
) : status === "ready" && onRemove ? (
|
|
200
|
+
<button
|
|
201
|
+
type="button"
|
|
202
|
+
aria-label={`Remove ${label}`}
|
|
203
|
+
onClick={onRemove}
|
|
204
|
+
className="grid size-5 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"
|
|
205
|
+
>
|
|
206
|
+
<XIcon className="size-3" aria-hidden="true" />
|
|
207
|
+
</button>
|
|
208
|
+
) : status === "ready" ? (
|
|
209
|
+
<CheckIcon className="size-3.5 text-kumo-success" aria-label="Ready" />
|
|
210
|
+
) : null}
|
|
211
|
+
</span>
|
|
212
|
+
{status !== "ready" && onRemove && (
|
|
213
|
+
<button
|
|
214
|
+
type="button"
|
|
215
|
+
aria-label={`Remove ${label}`}
|
|
216
|
+
onClick={onRemove}
|
|
217
|
+
className="grid size-5 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"
|
|
218
|
+
>
|
|
219
|
+
<XIcon className="size-3" aria-hidden="true" />
|
|
220
|
+
</button>
|
|
221
|
+
)}
|
|
222
|
+
{status === "uploading" && (
|
|
223
|
+
<span
|
|
224
|
+
aria-hidden="true"
|
|
225
|
+
className="absolute inset-x-0 bottom-0 h-0.5 bg-kumo-brand/70 transition-[width] duration-300"
|
|
226
|
+
style={{ width: `${Math.min(100, Math.max(0, progress * 100))}%` }}
|
|
227
|
+
/>
|
|
228
|
+
)}
|
|
229
|
+
</div>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (isImage) {
|
|
234
|
+
return (
|
|
235
|
+
<ImageFileView src={file.url!} label={label} placement={placement} />
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
return (
|
|
240
|
+
<span
|
|
241
|
+
className="inline-flex items-center gap-1.5 rounded-lg border border-kumo-line bg-kumo-elevated px-2.5 py-1.5 text-[12px] leading-4 text-kumo-subtle"
|
|
242
|
+
data-file-view=""
|
|
243
|
+
data-placement={placement}
|
|
244
|
+
>
|
|
245
|
+
<FileIcon size={13} className="text-kumo-inactive" />
|
|
246
|
+
{label}
|
|
247
|
+
</span>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** 宿主没有接管时回退到包内默认视图。 */
|
|
252
|
+
export function renderFileView(
|
|
253
|
+
renderFile: FileRenderer | undefined,
|
|
254
|
+
props: FileViewProps,
|
|
255
|
+
) {
|
|
256
|
+
const Renderer = renderFile ?? FileView;
|
|
257
|
+
return <Renderer {...props} />;
|
|
258
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@springbrand/message-panel/cloud-os`
|
|
3
|
+
*
|
|
4
|
+
* 从 cloudflare-os-main(packages/workshop-frontend)搬来的聊天体 + 右侧工作区面板,
|
|
5
|
+
* 适配到当前的 UIMessage 协议。这个文件夹**完全独立**:不 import `../src` 的任何东西,
|
|
6
|
+
* 自带 cn / 主题 / 图标 / markdown / primitives。
|
|
7
|
+
*
|
|
8
|
+
* 边界:
|
|
9
|
+
* - `styles/cloud-os.css` 必须由宿主的 Tailwind 入口 import,并把本目录加进 @source。
|
|
10
|
+
* - 所有组件都要包在 `<CloudOsRoot>` 里,否则拿不到作用域化的 token。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export { CloudOsRoot } from "./layout/cloud-os-root";
|
|
14
|
+
export { CapabilityChip } from "./capability-chip";
|
|
15
|
+
export type { CapabilityChipProps } from "./capability-chip";
|
|
16
|
+
export { FileView } from "./file-view";
|
|
17
|
+
export type {
|
|
18
|
+
FileRenderer,
|
|
19
|
+
FileViewFile,
|
|
20
|
+
FileViewPlacement,
|
|
21
|
+
FileViewProps,
|
|
22
|
+
FileViewStatus,
|
|
23
|
+
} from "./file-view";
|
|
24
|
+
export { CloudOsWorkspaceSplit } from "./layout/cloud-os-workspace-split";
|
|
25
|
+
export type { CloudOsWorkspaceSplitProps } from "./layout/cloud-os-workspace-split";
|
|
26
|
+
|
|
27
|
+
export { CloudOsChatMessages } from "./chat/cloud-os-chat-messages";
|
|
28
|
+
export type {
|
|
29
|
+
CloudOsChatMessagesProps,
|
|
30
|
+
CloudOsChatStatus,
|
|
31
|
+
} from "./chat/cloud-os-chat-messages";
|
|
32
|
+
|
|
33
|
+
export { CloudOsChatInput } from "./composer/cloud-os-chat-input";
|
|
34
|
+
export type {
|
|
35
|
+
CloudOsAttachmentView,
|
|
36
|
+
CloudOsCapabilityView,
|
|
37
|
+
CloudOsChatInputProps,
|
|
38
|
+
CloudOsComposerCommand,
|
|
39
|
+
CloudOsModelOption,
|
|
40
|
+
} from "./composer/cloud-os-chat-input";
|
|
41
|
+
|
|
42
|
+
export {
|
|
43
|
+
CloudOsWorkspacePanel,
|
|
44
|
+
NoContentPlaceholder,
|
|
45
|
+
PaneLabel,
|
|
46
|
+
PaneTab,
|
|
47
|
+
PaneWorkpieceTabs,
|
|
48
|
+
} from "./workspace/cloud-os-workspace-panel";
|
|
49
|
+
export type {
|
|
50
|
+
CloudOsPaneTab,
|
|
51
|
+
CloudOsWorkpieceTab,
|
|
52
|
+
CloudOsWorkspacePanelProps,
|
|
53
|
+
} from "./workspace/cloud-os-workspace-panel";
|
|
54
|
+
|
|
55
|
+
export { MarkdownMessage } from "./chat/markdown-message";
|
|
56
|
+
export type { CloudOsUrlResolver } from "./chat/markdown-message";
|
|
57
|
+
export {
|
|
58
|
+
ThinkingTraceRow,
|
|
59
|
+
ToolCallDetails,
|
|
60
|
+
ToolGroupRow,
|
|
61
|
+
WorkIcon,
|
|
62
|
+
} from "./chat/tool-rows";
|
|
63
|
+
export {
|
|
64
|
+
ApprovalBlock,
|
|
65
|
+
AskUserBlock,
|
|
66
|
+
ErrorBlock,
|
|
67
|
+
ParallelBlock,
|
|
68
|
+
PlanBlock,
|
|
69
|
+
PermissionGrant,
|
|
70
|
+
ScheduleBlock,
|
|
71
|
+
ScheduleConfirmation,
|
|
72
|
+
SubAgentsBlock,
|
|
73
|
+
SuggestionsBlock,
|
|
74
|
+
} from "./chat/rich-blocks";
|
|
75
|
+
export type { ApprovalDecision } from "./chat/rich-blocks";
|
|
76
|
+
|
|
77
|
+
export { CloudOsActivityIndicator } from "./chat/activity-indicator";
|
|
78
|
+
|
|
79
|
+
export {
|
|
80
|
+
buildCloudOsEntries,
|
|
81
|
+
cloudOsMetadata,
|
|
82
|
+
deriveTurnActivity,
|
|
83
|
+
formatClockTime,
|
|
84
|
+
formatFullTimestamp,
|
|
85
|
+
messageText,
|
|
86
|
+
requestedCapabilitiesOf,
|
|
87
|
+
rhythmTopClass,
|
|
88
|
+
} from "./chat/transcript-model";
|
|
89
|
+
export type {
|
|
90
|
+
AssistantBlock,
|
|
91
|
+
CloudOsActivity,
|
|
92
|
+
CloudOsAttachment,
|
|
93
|
+
CloudOsEntry,
|
|
94
|
+
CloudOsRequestedCapability,
|
|
95
|
+
ParallelTool,
|
|
96
|
+
PlanStep,
|
|
97
|
+
SubAgentView,
|
|
98
|
+
} from "./chat/transcript-model";
|
|
99
|
+
|
|
100
|
+
export {
|
|
101
|
+
buildToolCallGroups,
|
|
102
|
+
canonicalToolKind,
|
|
103
|
+
getToolCallSummary,
|
|
104
|
+
getToolIcon,
|
|
105
|
+
humanizeToolName,
|
|
106
|
+
toCloudOsToolCall,
|
|
107
|
+
toolNameOfPart,
|
|
108
|
+
} from "./chat/tool-presentation";
|
|
109
|
+
export type {
|
|
110
|
+
CloudOsToolCall,
|
|
111
|
+
CloudOsToolKind,
|
|
112
|
+
ToolCallGroup,
|
|
113
|
+
} from "./chat/tool-presentation";
|
|
114
|
+
|
|
115
|
+
export { DropdownMenu } from "./primitives/dropdown-menu";
|
|
116
|
+
export { Tooltip, TooltipProvider } from "./primitives/tooltip";
|
|
117
|
+
export {
|
|
118
|
+
CountBadge,
|
|
119
|
+
WorkshopButton,
|
|
120
|
+
WorkshopIconButton,
|
|
121
|
+
WorkshopInput,
|
|
122
|
+
} from "./primitives/workshop-controls";
|
|
123
|
+
export type { CloudOsMode } from "./internal/theme-context";
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
|
|
3
|
+
export type CloudOsMode = "light" | "dark";
|
|
4
|
+
|
|
5
|
+
const CloudOsModeContext = createContext<CloudOsMode>("light");
|
|
6
|
+
|
|
7
|
+
export const CloudOsModeProvider = CloudOsModeContext.Provider;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* portal 出去的浮层(Tooltip / DropdownMenu / Popover)脱离 `.cloud-os-root` 子树,
|
|
11
|
+
* 拿不到作用域里的 CSS 变量。浮层根节点自己带上 data-mode,配合
|
|
12
|
+
* `[data-cloud-os-portal][data-mode="dark"]` 那组规则补齐变量。
|
|
13
|
+
*/
|
|
14
|
+
export function useCloudOsMode(): CloudOsMode {
|
|
15
|
+
return useContext(CloudOsModeContext);
|
|
16
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { CloudOsModeProvider, type CloudOsMode } from "../internal/theme-context";
|
|
3
|
+
import { TooltipProvider } from "../primitives/tooltip";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* cloud-os 子树的根。做三件事:
|
|
7
|
+
* 1. 打上 `.cloud-os-root` —— 作用域化的通用 token(字体 / 圆角 / 缓动)挂在它上面,
|
|
8
|
+
* 这样 cloud-os 的 rounded-lg 是 0.5rem,而外层宿主的 shadcn 主题不受影响。
|
|
9
|
+
* 2. 打上 `data-mode` —— 暗色 token 整组由它切换。
|
|
10
|
+
* 3. 提供 Tooltip 上下文,并把 mode 透给 portal 出去的浮层。
|
|
11
|
+
*/
|
|
12
|
+
export function CloudOsRoot({
|
|
13
|
+
mode = "light",
|
|
14
|
+
className = "",
|
|
15
|
+
children,
|
|
16
|
+
}: {
|
|
17
|
+
mode?: CloudOsMode;
|
|
18
|
+
className?: string;
|
|
19
|
+
children: ReactNode;
|
|
20
|
+
}) {
|
|
21
|
+
return (
|
|
22
|
+
<CloudOsModeProvider value={mode}>
|
|
23
|
+
<TooltipProvider>
|
|
24
|
+
<div
|
|
25
|
+
className={`cloud-os-root ${className}`}
|
|
26
|
+
data-mode={mode}
|
|
27
|
+
data-cloud-os-root=""
|
|
28
|
+
>
|
|
29
|
+
{children}
|
|
30
|
+
</div>
|
|
31
|
+
</TooltipProvider>
|
|
32
|
+
</CloudOsModeProvider>
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
type PointerEvent as ReactPointerEvent,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from "react";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 左聊天 / 右工作区的双栏骨架。从 cloudflare-os-main `GadgetEditor.tsx` 的 BODY
|
|
12
|
+
* 段拷来:同一条 1px 的 kumo-line 分隔即拖拽把手、同一套 pointer capture 拖拽
|
|
13
|
+
* (拖过 iframe 也不丢)、同一个 200ms 的收起/展开过渡。
|
|
14
|
+
*
|
|
15
|
+
* 适配点:原文件把左右两侧的内容写死成 ChatInterface / GadgetUI,这里换成
|
|
16
|
+
* children 插槽;chatWidth 的 localStorage key 换成 cloud-os 自己的。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const CHAT_WIDTH_STORAGE_KEY = "cloud-os:chatWidth";
|
|
20
|
+
const MIN_CHAT_WIDTH = 280;
|
|
21
|
+
const MIN_WORKSPACE_WIDTH = 400;
|
|
22
|
+
const DEFAULT_CHAT_WIDTH = 420;
|
|
23
|
+
const WORKSPACE_TRANSITION_MS = 200;
|
|
24
|
+
|
|
25
|
+
const isBrowser = typeof window !== "undefined";
|
|
26
|
+
|
|
27
|
+
function clampChatWidth(width: number, containerWidth?: number) {
|
|
28
|
+
if (!isBrowser) {
|
|
29
|
+
return Math.max(MIN_CHAT_WIDTH, Math.min(DEFAULT_CHAT_WIDTH, width));
|
|
30
|
+
}
|
|
31
|
+
const available = containerWidth ?? window.innerWidth;
|
|
32
|
+
const max = Math.max(MIN_CHAT_WIDTH, available - MIN_WORKSPACE_WIDTH);
|
|
33
|
+
return Math.max(MIN_CHAT_WIDTH, Math.min(max, width));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getInitialChatWidth() {
|
|
37
|
+
if (!isBrowser) return DEFAULT_CHAT_WIDTH;
|
|
38
|
+
const fallback = Math.min(
|
|
39
|
+
DEFAULT_CHAT_WIDTH,
|
|
40
|
+
Math.floor(window.innerWidth * 0.38),
|
|
41
|
+
);
|
|
42
|
+
let parsed = Number.NaN;
|
|
43
|
+
try {
|
|
44
|
+
const stored = window.localStorage.getItem(CHAT_WIDTH_STORAGE_KEY);
|
|
45
|
+
if (stored) parsed = Number(stored);
|
|
46
|
+
} catch {
|
|
47
|
+
// 隐私模式 / 沙箱 iframe 下没有 storage,退回默认值即可。
|
|
48
|
+
}
|
|
49
|
+
return clampChatWidth(Number.isFinite(parsed) ? parsed : fallback);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type ResizeSession = {
|
|
53
|
+
startX: number;
|
|
54
|
+
startWidth: number;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export interface CloudOsWorkspaceSplitProps {
|
|
58
|
+
chat: ReactNode;
|
|
59
|
+
workspace: ReactNode;
|
|
60
|
+
/** 关掉右栏时聊天占满整宽。 */
|
|
61
|
+
workspaceOpen: boolean;
|
|
62
|
+
/** 右栏收起时依然保留的窄边栏宽度(原文件的 Outputs rail)。 */
|
|
63
|
+
railWidth?: number;
|
|
64
|
+
rail?: ReactNode;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function CloudOsWorkspaceSplit({
|
|
68
|
+
chat,
|
|
69
|
+
workspace,
|
|
70
|
+
workspaceOpen,
|
|
71
|
+
railWidth = 0,
|
|
72
|
+
rail,
|
|
73
|
+
}: CloudOsWorkspaceSplitProps) {
|
|
74
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
75
|
+
const resizeSessionRef = useRef<ResizeSession | null>(null);
|
|
76
|
+
const [chatWidth, setChatWidth] = useState(getInitialChatWidth);
|
|
77
|
+
const [isResizing, setIsResizing] = useState(false);
|
|
78
|
+
const [transitionEnabled, setTransitionEnabled] = useState(false);
|
|
79
|
+
const chatWidthRef = useRef(chatWidth);
|
|
80
|
+
chatWidthRef.current = chatWidth;
|
|
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
|
+
|
|
92
|
+
// 首帧不要动画:否则每次挂载都会看到面板「滑进来」。
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
const timer = window.setTimeout(() => setTransitionEnabled(true), 0);
|
|
95
|
+
return () => window.clearTimeout(timer);
|
|
96
|
+
}, []);
|
|
97
|
+
|
|
98
|
+
// 挂载后按真实容器宽度重新 clamp 一次(宿主可能有左侧 sidebar 等偏移)。
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
setChatWidth((width) => clampToContainer(width));
|
|
101
|
+
}, [clampToContainer]);
|
|
102
|
+
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
const onResize = () => setChatWidth((width) => clampToContainer(width));
|
|
105
|
+
window.addEventListener("resize", onResize);
|
|
106
|
+
return () => window.removeEventListener("resize", onResize);
|
|
107
|
+
}, [clampToContainer]);
|
|
108
|
+
|
|
109
|
+
const persistChatWidth = useCallback((width: number) => {
|
|
110
|
+
try {
|
|
111
|
+
window.localStorage.setItem(CHAT_WIDTH_STORAGE_KEY, String(width));
|
|
112
|
+
} catch {
|
|
113
|
+
// storage 不可用时,本次会话内仍然生效。
|
|
114
|
+
}
|
|
115
|
+
}, []);
|
|
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
|
+
|
|
126
|
+
// 用 pointer capture:拖过右侧 iframe 时事件也不会丢。
|
|
127
|
+
const handleResizePointerDown = useCallback(
|
|
128
|
+
(event: ReactPointerEvent<HTMLDivElement>) => {
|
|
129
|
+
if (!workspaceOpen) return;
|
|
130
|
+
event.preventDefault();
|
|
131
|
+
resizeSessionRef.current = {
|
|
132
|
+
startX: event.clientX,
|
|
133
|
+
startWidth: chatWidthRef.current,
|
|
134
|
+
};
|
|
135
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
136
|
+
setIsResizing(true);
|
|
137
|
+
},
|
|
138
|
+
[workspaceOpen],
|
|
139
|
+
);
|
|
140
|
+
const handleResizePointerMove = useCallback(
|
|
141
|
+
(event: ReactPointerEvent<HTMLDivElement>) => {
|
|
142
|
+
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return;
|
|
143
|
+
setChatWidth(widthFromPointer(event.clientX));
|
|
144
|
+
},
|
|
145
|
+
[widthFromPointer],
|
|
146
|
+
);
|
|
147
|
+
const handleResizePointerUp = useCallback(
|
|
148
|
+
(event: ReactPointerEvent<HTMLDivElement>) => {
|
|
149
|
+
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
|
150
|
+
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
151
|
+
}
|
|
152
|
+
const width =
|
|
153
|
+
event.type === "pointercancel"
|
|
154
|
+
? chatWidthRef.current
|
|
155
|
+
: widthFromPointer(event.clientX);
|
|
156
|
+
resizeSessionRef.current = null;
|
|
157
|
+
setChatWidth(width);
|
|
158
|
+
persistChatWidth(width);
|
|
159
|
+
setIsResizing(false);
|
|
160
|
+
},
|
|
161
|
+
[persistChatWidth, widthFromPointer],
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
if (!isResizing) return;
|
|
166
|
+
document.body.style.userSelect = "none";
|
|
167
|
+
document.body.style.cursor = "col-resize";
|
|
168
|
+
return () => {
|
|
169
|
+
document.body.style.userSelect = "";
|
|
170
|
+
document.body.style.cursor = "";
|
|
171
|
+
};
|
|
172
|
+
}, [isResizing]);
|
|
173
|
+
|
|
174
|
+
// 类名必须是字面量:Tailwind 扫源码找不到拼出来的 duration-[200ms]。
|
|
175
|
+
// WORKSPACE_TRANSITION_MS 和这里的 duration-200 必须一起改。
|
|
176
|
+
const transitionClass =
|
|
177
|
+
transitionEnabled && !isResizing
|
|
178
|
+
? "transition-[width,opacity] duration-200 ease-out"
|
|
179
|
+
: "";
|
|
180
|
+
|
|
181
|
+
return (
|
|
182
|
+
// h-full 而不是只靠 flex-1:宿主给的容器不一定是 flex,那样 flex-1 不生效,
|
|
183
|
+
// 整个分栏会塌成内容高度(聊天区不铺满、输入框吊在半空)。
|
|
184
|
+
<div
|
|
185
|
+
ref={containerRef}
|
|
186
|
+
className="relative flex h-full min-h-0 flex-1 overflow-hidden bg-kumo-base"
|
|
187
|
+
>
|
|
188
|
+
{/* ── 左:聊天 ──────────────────────────────────────────────────────── */}
|
|
189
|
+
<div
|
|
190
|
+
className={`flex h-full min-h-0 flex-shrink-0 flex-col ${transitionClass} ${
|
|
191
|
+
workspaceOpen ? "border-r border-kumo-line" : ""
|
|
192
|
+
}`}
|
|
193
|
+
style={{
|
|
194
|
+
width: workspaceOpen ? chatWidth : `calc(100% - ${railWidth}px)`,
|
|
195
|
+
}}
|
|
196
|
+
>
|
|
197
|
+
{chat}
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
{/* ── 拖拽把手 ─────────────────────────────────────────────────────── */}
|
|
201
|
+
<div
|
|
202
|
+
className={`relative flex-shrink-0 cursor-col-resize touch-none overflow-visible bg-kumo-line ${transitionClass}`}
|
|
203
|
+
style={{ width: workspaceOpen ? 1 : 0 }}
|
|
204
|
+
onPointerDown={handleResizePointerDown}
|
|
205
|
+
onPointerMove={handleResizePointerMove}
|
|
206
|
+
onPointerUp={handleResizePointerUp}
|
|
207
|
+
onPointerCancel={handleResizePointerUp}
|
|
208
|
+
>
|
|
209
|
+
<div className="absolute inset-y-0 -left-2 -right-2" />
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
{/* ── 右:工作区 ───────────────────────────────────────────────────── */}
|
|
213
|
+
<div
|
|
214
|
+
className={`flex h-full min-w-0 flex-shrink-0 overflow-hidden bg-kumo-base ${transitionClass}`}
|
|
215
|
+
style={{
|
|
216
|
+
width: workspaceOpen ? `calc(100% - ${chatWidth}px - 1px)` : 0,
|
|
217
|
+
opacity: workspaceOpen ? 1 : 0,
|
|
218
|
+
}}
|
|
219
|
+
>
|
|
220
|
+
<div className="flex min-w-0 flex-1 flex-col overflow-hidden">
|
|
221
|
+
{workspace}
|
|
222
|
+
</div>
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
{rail}
|
|
226
|
+
</div>
|
|
227
|
+
);
|
|
228
|
+
}
|