@canmingir/link 1.2.54 → 1.2.56

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.
@@ -0,0 +1,142 @@
1
+ import Editor from "@monaco-editor/react";
2
+ import { alpha } from "@mui/material/styles";
3
+ import { Box, Stack, Typography } from "@mui/material";
4
+
5
+ import { memo, useMemo } from "react";
6
+
7
+ function tryParseJson(content: string): boolean {
8
+ const trimmed = content.trim();
9
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) return false;
10
+ try {
11
+ JSON.parse(trimmed);
12
+ return true;
13
+ } catch {
14
+ return false;
15
+ }
16
+ }
17
+
18
+ const HumanMessage = memo(
19
+ ({
20
+ message,
21
+ selectedId,
22
+ messageRef,
23
+ }: {
24
+ message: { id: string; content: string };
25
+ selectedId: string;
26
+ messageRef: React.RefObject<HTMLDivElement>;
27
+ }) => {
28
+ const isJson = useMemo(
29
+ () => tryParseJson(message.content),
30
+ [message.content]
31
+ );
32
+
33
+ const formattedJson = useMemo(() => {
34
+ if (!isJson) return null;
35
+ try {
36
+ return JSON.stringify(JSON.parse(message.content), null, 2);
37
+ } catch {
38
+ return null;
39
+ }
40
+ }, [isJson, message.content]);
41
+
42
+ const editorHeight = useMemo(() => {
43
+ if (!formattedJson) return 0;
44
+ const lines = formattedJson.split("\n").length;
45
+ return Math.min(Math.max(lines * 19 + 24, 60), 200);
46
+ }, [formattedJson]);
47
+
48
+ return (
49
+ <Stack
50
+ ref={messageRef}
51
+ sx={{
52
+ p: isJson ? 1.5 : 2.5,
53
+ minHeight: "auto",
54
+ mt: 1.5,
55
+ ml: isJson ? 0 : "auto",
56
+ maxWidth: isJson ? "100%" : "85%",
57
+ alignContent: "center",
58
+ justifyContent: "center",
59
+ borderWidth: message?.id === selectedId ? 3 : 0,
60
+ borderRadius: isJson ? 2 : "16px 16px 4px 16px",
61
+ borderStyle: "none",
62
+ borderColor: "transparent",
63
+ backgroundColor: (theme) =>
64
+ isJson
65
+ ? alpha(theme.palette.grey[800], 0.5)
66
+ : alpha(theme.palette.grey[700], 0.4),
67
+ animation: "none",
68
+ boxShadow: 1,
69
+ }}
70
+ >
71
+ {isJson ? (
72
+ <Box>
73
+ <Typography
74
+ variant="caption"
75
+ sx={{
76
+ display: "block",
77
+ mb: 0.75,
78
+ color: (theme) => alpha(theme.palette.text.secondary, 0.6),
79
+ fontSize: "0.68rem",
80
+ fontWeight: 600,
81
+ textTransform: "uppercase",
82
+ letterSpacing: 0.5,
83
+ }}
84
+ >
85
+ JSON Request
86
+ </Typography>
87
+ <Box
88
+ sx={{
89
+ border: (theme) =>
90
+ `1px solid ${alpha(theme.palette.grey[500], 0.2)}`,
91
+ borderRadius: 1,
92
+ overflow: "hidden",
93
+ bgcolor: "#1e1e1e",
94
+ }}
95
+ >
96
+ <Editor
97
+ height={`${editorHeight}px`}
98
+ defaultLanguage="json"
99
+ value={formattedJson}
100
+ theme="vs-dark"
101
+ options={{
102
+ readOnly: true,
103
+ minimap: { enabled: false },
104
+ fontSize: 12,
105
+ lineNumbers: "off",
106
+ scrollBeyondLastLine: false,
107
+ wordWrap: "on",
108
+ tabSize: 2,
109
+ folding: false,
110
+ glyphMargin: false,
111
+ lineDecorationsWidth: 0,
112
+ lineNumbersMinChars: 0,
113
+ padding: { top: 8, bottom: 8 },
114
+ scrollbar: { vertical: "hidden", horizontal: "hidden" },
115
+ renderLineHighlight: "none",
116
+ selectionHighlight: false,
117
+ occurrencesHighlight: "off",
118
+ contextmenu: false,
119
+ domReadOnly: true,
120
+ }}
121
+ />
122
+ </Box>
123
+ </Box>
124
+ ) : (
125
+ <Typography
126
+ variant="body1"
127
+ textAlign="end"
128
+ sx={{
129
+ fontSize: "0.95rem",
130
+ lineHeight: 1.6,
131
+ wordWrap: "break-word",
132
+ }}
133
+ >
134
+ {message.content}
135
+ </Typography>
136
+ )}
137
+ </Stack>
138
+ );
139
+ }
140
+ );
141
+
142
+ export { HumanMessage };
@@ -0,0 +1,27 @@
1
+ import { Iconify } from "@canmingir/link/platform/components";
2
+ import { Stack } from "@mui/material";
3
+ import { alpha } from "@mui/material/styles";
4
+ import { memo } from "react";
5
+
6
+ const LoadingMessage = memo(
7
+ ({
8
+ messagesEndRef,
9
+ }: {
10
+ messagesEndRef: { current: HTMLDivElement | null };
11
+ }) => (
12
+ <Stack
13
+ ref={messagesEndRef}
14
+ sx={{
15
+ p: 2,
16
+ height: 50,
17
+ backgroundColor: (theme) => alpha(theme.palette.primary.dark, 0.5),
18
+ borderRadius: 1,
19
+ mt: 1,
20
+ }}
21
+ >
22
+ <Iconify icon="svg-spinners:tadpole" sx={{ width: 25, height: 25 }} />
23
+ </Stack>
24
+ )
25
+ );
26
+
27
+ export { LoadingMessage };
@@ -0,0 +1,81 @@
1
+ import { AIMessage } from "./AIMessage";
2
+ import { ErrorMessage } from "./ErrorMessage";
3
+ import { HumanMessage } from "./HumanMessage";
4
+ import { ToolDecision, ToolMessage, ToolRenderers } from "./ToolMessage";
5
+
6
+ import React, { memo } from "react";
7
+
8
+ const MessageList = memo(
9
+ ({
10
+ error,
11
+ messages,
12
+ selectedId,
13
+ messagesEndRef,
14
+ highlightedMessage,
15
+ onErrorClose,
16
+ toolRenderers,
17
+ onToolDecision,
18
+ }: {
19
+ error?: string;
20
+ messages: { id: string; content: string; role: string }[];
21
+ selectedId: string;
22
+ messagesEndRef: { current: HTMLDivElement | null };
23
+ highlightedMessage: { current: HTMLDivElement | null };
24
+ onErrorClose?: () => void;
25
+ toolRenderers?: ToolRenderers;
26
+ onToolDecision?: (toolCallId: string, decision: ToolDecision) => void;
27
+ }) => (
28
+ <>
29
+ {messages.map((item, index) => {
30
+ const isLastMessage = index === messages.length - 1;
31
+ const isSelected = item.id === selectedId;
32
+
33
+ if (item.role === "USER") {
34
+ return (
35
+ <HumanMessage
36
+ key={item.id || index}
37
+ message={item}
38
+ selectedId={selectedId}
39
+ messageRef={
40
+ isSelected
41
+ ? highlightedMessage
42
+ : isLastMessage
43
+ ? messagesEndRef
44
+ : undefined
45
+ }
46
+ />
47
+ );
48
+ }
49
+
50
+ if (item.role === "TOOL") {
51
+ return (
52
+ <ToolMessage
53
+ key={item.id || index}
54
+ content={item.content}
55
+ messageRef={isLastMessage ? messagesEndRef : undefined}
56
+ renderers={toolRenderers}
57
+ onDecision={onToolDecision}
58
+ />
59
+ );
60
+ }
61
+
62
+ return (
63
+ <AIMessage
64
+ key={item.id || index}
65
+ content={item.content}
66
+ messageRef={isLastMessage ? messagesEndRef : undefined}
67
+ />
68
+ );
69
+ })}
70
+ {error && (
71
+ <ErrorMessage
72
+ key="error-message"
73
+ content={error}
74
+ onClose={onErrorClose}
75
+ />
76
+ )}
77
+ </>
78
+ )
79
+ );
80
+
81
+ export { MessageList };
@@ -0,0 +1,346 @@
1
+ import { Iconify } from "@canmingir/link/platform/components";
2
+ import { alpha } from "@mui/material/styles";
3
+
4
+ import { Box, Button, Collapse, Stack, Typography } from "@mui/material";
5
+ import React, { memo, useMemo, useState } from "react";
6
+
7
+ export type ToolStatus =
8
+ | "awaiting_approval"
9
+ | "pending"
10
+ | "success"
11
+ | "error"
12
+ | "declined";
13
+
14
+ export type ToolDecision = "approve" | "decline" | "approve_all";
15
+
16
+ export interface ToolMessageContent {
17
+ id: string;
18
+ name: string;
19
+ input?: Record<string, unknown>;
20
+ output?: string;
21
+ status: ToolStatus;
22
+ }
23
+
24
+ export interface ToolRendererProps {
25
+ name: string;
26
+ input?: Record<string, unknown>;
27
+ output?: string;
28
+ status: ToolStatus;
29
+ }
30
+
31
+ export type ToolRenderers = Record<
32
+ string,
33
+ React.ComponentType<ToolRendererProps>
34
+ >;
35
+
36
+ function formatLabel(name: string): string {
37
+ return name
38
+ .split("_")
39
+ .filter(Boolean)
40
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
41
+ .join(" ");
42
+ }
43
+
44
+ function formatValue(value: unknown): string {
45
+ if (value === null || value === undefined || value === "") return "—";
46
+ if (typeof value === "string") return value;
47
+ if (typeof value === "number" || typeof value === "boolean") {
48
+ return String(value);
49
+ }
50
+ if (Array.isArray(value)) {
51
+ return value.length ? value.map(formatValue).join(", ") : "—";
52
+ }
53
+ if (typeof value === "object") {
54
+ return Object.entries(value as Record<string, unknown>)
55
+ .map(([key, val]) => `${formatLabel(key)}: ${formatValue(val)}`)
56
+ .join(", ");
57
+ }
58
+ return String(value);
59
+ }
60
+
61
+ function statusIcon(status: ToolStatus): string {
62
+ if (status === "success") return "solar:check-circle-bold";
63
+ if (status === "error") return "solar:close-circle-bold";
64
+ if (status === "declined") return "solar:forbidden-circle-bold";
65
+ if (status === "awaiting_approval") return "solar:shield-warning-bold";
66
+ return "svg-spinners:tadpole";
67
+ }
68
+
69
+ function statusColor(
70
+ status: ToolStatus
71
+ ): "success.main" | "error.main" | "warning.main" | "text.secondary" {
72
+ if (status === "success") return "success.main";
73
+ if (status === "error") return "error.main";
74
+ if (status === "awaiting_approval") return "warning.main";
75
+ return "text.secondary";
76
+ }
77
+
78
+ function FieldRow({ label, value }: { label: string; value: unknown }) {
79
+ return (
80
+ <Stack direction="row" spacing={1} sx={{ py: 0.4 }}>
81
+ <Typography
82
+ sx={{
83
+ fontSize: "0.72rem",
84
+ fontWeight: 600,
85
+ color: "text.secondary",
86
+ minWidth: 90,
87
+ flexShrink: 0,
88
+ }}
89
+ >
90
+ {label}
91
+ </Typography>
92
+ <Typography sx={{ fontSize: "0.78rem", wordBreak: "break-word" }}>
93
+ {formatValue(value)}
94
+ </Typography>
95
+ </Stack>
96
+ );
97
+ }
98
+
99
+ function FieldList({ data }: { data: Record<string, unknown> }) {
100
+ const entries = Object.entries(data);
101
+ if (!entries.length) {
102
+ return (
103
+ <Typography sx={{ fontSize: "0.78rem", color: "text.secondary" }}>
104
+ No fields
105
+ </Typography>
106
+ );
107
+ }
108
+
109
+ return (
110
+ <Stack>
111
+ {entries.map(([key, value]) => (
112
+ <FieldRow key={key} label={formatLabel(key)} value={value} />
113
+ ))}
114
+ </Stack>
115
+ );
116
+ }
117
+
118
+ function ResultView({ output }: { output: string }) {
119
+ const parsed = useMemo(() => {
120
+ try {
121
+ return JSON.parse(output);
122
+ } catch {
123
+ return undefined;
124
+ }
125
+ }, [output]);
126
+
127
+ if (parsed === undefined) {
128
+ return (
129
+ <Typography sx={{ fontSize: "0.78rem", whiteSpace: "pre-wrap" }}>
130
+ {output}
131
+ </Typography>
132
+ );
133
+ }
134
+
135
+ if (Array.isArray(parsed)) {
136
+ if (!parsed.length) {
137
+ return (
138
+ <Typography sx={{ fontSize: "0.78rem", color: "text.secondary" }}>
139
+ Empty result
140
+ </Typography>
141
+ );
142
+ }
143
+
144
+ return (
145
+ <Stack spacing={1}>
146
+ {parsed.map((item, index) =>
147
+ item && typeof item === "object" ? (
148
+ <Box
149
+ key={index}
150
+ sx={{
151
+ pl: 1,
152
+ borderLeft: (theme) =>
153
+ `2px solid ${alpha(theme.palette.grey[500], 0.3)}`,
154
+ }}
155
+ >
156
+ <FieldList data={item as Record<string, unknown>} />
157
+ </Box>
158
+ ) : (
159
+ <Typography key={index} sx={{ fontSize: "0.78rem" }}>
160
+ {formatValue(item)}
161
+ </Typography>
162
+ )
163
+ )}
164
+ </Stack>
165
+ );
166
+ }
167
+
168
+ if (parsed && typeof parsed === "object") {
169
+ return <FieldList data={parsed as Record<string, unknown>} />;
170
+ }
171
+
172
+ return (
173
+ <Typography sx={{ fontSize: "0.78rem" }}>{formatValue(parsed)}</Typography>
174
+ );
175
+ }
176
+
177
+ function ApprovalActions({
178
+ toolCallId,
179
+ onDecision,
180
+ }: {
181
+ toolCallId: string;
182
+ onDecision: (toolCallId: string, decision: ToolDecision) => void;
183
+ }) {
184
+ return (
185
+ <Stack direction="row" spacing={1} sx={{ mt: 1 }}>
186
+ <Button
187
+ size="small"
188
+ variant="contained"
189
+ color="success"
190
+ onClick={() => onDecision(toolCallId, "approve")}
191
+ sx={{ fontSize: "0.7rem", py: 0.25 }}
192
+ >
193
+ Approve
194
+ </Button>
195
+ <Button
196
+ size="small"
197
+ variant="outlined"
198
+ color="error"
199
+ onClick={() => onDecision(toolCallId, "decline")}
200
+ sx={{ fontSize: "0.7rem", py: 0.25 }}
201
+ >
202
+ Decline
203
+ </Button>
204
+ <Button
205
+ size="small"
206
+ variant="text"
207
+ onClick={() => onDecision(toolCallId, "approve_all")}
208
+ sx={{ fontSize: "0.7rem", py: 0.25 }}
209
+ >
210
+ Approve All
211
+ </Button>
212
+ </Stack>
213
+ );
214
+ }
215
+
216
+ const ToolMessage: React.FC<{
217
+ content: string;
218
+ messageRef?: React.RefObject<HTMLDivElement>;
219
+ renderers?: ToolRenderers;
220
+ onDecision?: (toolCallId: string, decision: ToolDecision) => void;
221
+ }> = memo(({ content, messageRef, renderers, onDecision }) => {
222
+ const [expanded, setExpanded] = useState(false);
223
+
224
+ const tool = useMemo<ToolMessageContent | null>(() => {
225
+ try {
226
+ const parsed = JSON.parse(content);
227
+ if (parsed && typeof parsed.name === "string") return parsed;
228
+ return null;
229
+ } catch {
230
+ return null;
231
+ }
232
+ }, [content]);
233
+
234
+ if (!tool) return null;
235
+
236
+ const CustomBody = renderers?.[tool.name];
237
+ const awaitingApproval = tool.status === "awaiting_approval";
238
+ const alwaysShowBody = CustomBody || awaitingApproval;
239
+
240
+ return (
241
+ <Stack
242
+ ref={messageRef}
243
+ sx={{
244
+ p: 1.5,
245
+ mt: 1.5,
246
+ borderRadius: 2,
247
+ backgroundColor: (theme) => alpha(theme.palette.grey[800], 0.4),
248
+ border: (theme) =>
249
+ `1px solid ${alpha(
250
+ awaitingApproval ? theme.palette.warning.main : theme.palette.grey[500],
251
+ awaitingApproval ? 0.4 : 0.2
252
+ )}`,
253
+ }}
254
+ >
255
+ <Stack
256
+ direction="row"
257
+ spacing={1}
258
+ alignItems="center"
259
+ onClick={alwaysShowBody ? undefined : () => setExpanded((e) => !e)}
260
+ sx={{ cursor: alwaysShowBody ? "default" : "pointer" }}
261
+ >
262
+ <Iconify
263
+ icon="solar:widget-bold-duotone"
264
+ sx={{ width: 18, height: 18, color: "text.secondary" }}
265
+ />
266
+ <Typography sx={{ fontSize: "0.8rem", fontWeight: 600, flex: 1 }}>
267
+ {formatLabel(tool.name)}
268
+ </Typography>
269
+ <Iconify
270
+ icon={statusIcon(tool.status)}
271
+ sx={{ width: 16, height: 16, color: statusColor(tool.status) }}
272
+ />
273
+ {!alwaysShowBody && (
274
+ <Iconify
275
+ icon={expanded ? "mdi:chevron-up" : "mdi:chevron-down"}
276
+ sx={{ width: 16, height: 16, color: "text.secondary" }}
277
+ />
278
+ )}
279
+ </Stack>
280
+
281
+ {CustomBody ? (
282
+ <Box sx={{ mt: 1 }}>
283
+ <CustomBody
284
+ name={tool.name}
285
+ input={tool.input}
286
+ output={tool.output}
287
+ status={tool.status}
288
+ />
289
+ </Box>
290
+ ) : awaitingApproval ? (
291
+ <Box sx={{ mt: 1 }}>
292
+ {tool.input && <FieldList data={tool.input} />}
293
+ </Box>
294
+ ) : (
295
+ <Collapse in={expanded}>
296
+ <Box sx={{ mt: 1 }}>
297
+ {tool.input && (
298
+ <>
299
+ <Typography
300
+ variant="caption"
301
+ sx={{
302
+ display: "block",
303
+ color: (theme) => alpha(theme.palette.text.secondary, 0.7),
304
+ fontSize: "0.65rem",
305
+ fontWeight: 600,
306
+ textTransform: "uppercase",
307
+ letterSpacing: 0.5,
308
+ }}
309
+ >
310
+ Arguments
311
+ </Typography>
312
+ <FieldList data={tool.input} />
313
+ </>
314
+ )}
315
+
316
+ {tool.output !== undefined && (
317
+ <>
318
+ <Typography
319
+ variant="caption"
320
+ sx={{
321
+ display: "block",
322
+ mt: 1,
323
+ color: (theme) => alpha(theme.palette.text.secondary, 0.7),
324
+ fontSize: "0.65rem",
325
+ fontWeight: 600,
326
+ textTransform: "uppercase",
327
+ letterSpacing: 0.5,
328
+ }}
329
+ >
330
+ Result
331
+ </Typography>
332
+ <ResultView output={tool.output} />
333
+ </>
334
+ )}
335
+ </Box>
336
+ </Collapse>
337
+ )}
338
+
339
+ {awaitingApproval && onDecision && (
340
+ <ApprovalActions toolCallId={tool.id} onDecision={onDecision} />
341
+ )}
342
+ </Stack>
343
+ );
344
+ });
345
+
346
+ export { ToolMessage };
@@ -0,0 +1,6 @@
1
+ export { AIMessage } from "./AIMessage";
2
+ export { ErrorMessage } from "./ErrorMessage";
3
+ export { HumanMessage } from "./HumanMessage";
4
+ export { LoadingMessage } from "./LoadingMessage";
5
+ export { MessageList } from "./MessageList";
6
+ export { ToolMessage } from "./ToolMessage";
@@ -14,6 +14,7 @@ export const Flow = ({
14
14
  onChange,
15
15
  height,
16
16
  initialZoom,
17
+ centered = false,
17
18
  }) => {
18
19
  const [floatingNodes, setFloatingNodes] = useState([]);
19
20
 
@@ -90,6 +91,7 @@ export const Flow = ({
90
91
  floatingNodes={floatingNodes}
91
92
  height={height}
92
93
  initialZoom={initialZoom}
94
+ centered={centered}
93
95
  />
94
96
  </Box>
95
97
  );
@@ -18,6 +18,7 @@ const FlowNode = ({
18
18
  node,
19
19
  height,
20
20
  initialZoom,
21
+ centered,
21
22
  ...props
22
23
  }) => {
23
24
  if (!isRoot) {
@@ -52,6 +53,7 @@ const FlowNode = ({
52
53
  plugin={plugin}
53
54
  height={height}
54
55
  initialZoom={initialZoom}
56
+ centered={centered}
55
57
  >
56
58
  {node && (
57
59
  <FlowNodeView
@@ -18,6 +18,7 @@ const FlowViewport = ({
18
18
  plugin,
19
19
  height = "100vh",
20
20
  initialZoom = 1,
21
+ centered = false,
21
22
  sx = {},
22
23
  ...rest
23
24
  }) => {
@@ -45,6 +46,8 @@ const FlowViewport = ({
45
46
  } = useSelection();
46
47
 
47
48
  useEffect(() => {
49
+ if (centered) return;
50
+
48
51
  const container = containerRef.current;
49
52
  const inner = innerRef.current;
50
53
  if (!container || !inner) return;
@@ -64,7 +67,7 @@ const FlowViewport = ({
64
67
  observer.observe(container);
65
68
 
66
69
  return () => observer.disconnect();
67
- }, []);
70
+ }, [centered]);
68
71
 
69
72
  useEffect(() => {
70
73
  const handleMouseMove = (e) => {
@@ -310,11 +313,11 @@ const FlowViewport = ({
310
313
  height: height,
311
314
  display: "flex",
312
315
  alignItems: "center",
313
- justifyContent: shouldCenter ? "center" : "flex-start",
316
+ justifyContent: centered || shouldCenter ? "center" : "flex-start",
314
317
  transition: isDragging ? "none" : "transform 0.1s ease-out",
315
318
  pointerEvents: "auto",
316
319
  position: "relative",
317
- pl: variant === "horizontal" ? 4 : 0,
320
+ pl: centered ? 0 : variant === "horizontal" ? 4 : 0,
318
321
  }}
319
322
  >
320
323
  {children}