devnonla-ui 0.1.0
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/LICENSE +21 -0
- package/README.md +140 -0
- package/package.json +66 -0
- package/src/alert/Alert.tsx +109 -0
- package/src/app/App.tsx +36 -0
- package/src/button/Button.tsx +278 -0
- package/src/button/ButtonCopy.tsx +60 -0
- package/src/calendar/Calendar.tsx +133 -0
- package/src/chat/AgentPanel.tsx +203 -0
- package/src/chat/common/bgTasks.ts +41 -0
- package/src/chat/common/sse.ts +156 -0
- package/src/chat/common/types.ts +43 -0
- package/src/chat/common/useAgentStream.ts +351 -0
- package/src/chat/common/utils.ts +73 -0
- package/src/chat/index.ts +56 -0
- package/src/chat/message-ui/ChatAgentMessage.tsx +29 -0
- package/src/chat/message-ui/ChatError.tsx +24 -0
- package/src/chat/message-ui/ChatInput.tsx +162 -0
- package/src/chat/message-ui/ChatMarkdown.tsx +128 -0
- package/src/chat/message-ui/ChatMarkdownTable.tsx +45 -0
- package/src/chat/message-ui/ChatSpinner.tsx +5 -0
- package/src/chat/message-ui/ChatThinking.tsx +61 -0
- package/src/chat/message-ui/ChatUserMessage.tsx +72 -0
- package/src/chat/message-ui/ChatWelcome.tsx +70 -0
- package/src/chat/message-ui/MermaidBlock.tsx +266 -0
- package/src/chat/message-ui/mermaidFence.ts +55 -0
- package/src/chat/message-ui/sanitizeMermaid.ts +135 -0
- package/src/chat/tool-ui/BackgroundTaskToolUI.tsx +36 -0
- package/src/chat/tool-ui/BackgroundTasksBar.tsx +152 -0
- package/src/chat/tool-ui/CallAgentToolUI.tsx +168 -0
- package/src/chat/tool-ui/ChatToolCall.tsx +77 -0
- package/src/chat/tool-ui/GetCurrentTimeToolUI.tsx +49 -0
- package/src/chat/tool-ui/ReadSkillToolUI.tsx +80 -0
- package/src/chat/tool-ui/RunJsToolUI.tsx +106 -0
- package/src/chat/tool-ui/ToolUiBadge.tsx +10 -0
- package/src/chat/tool-ui/ToolUiTrailing.tsx +24 -0
- package/src/chat/tool-ui/WebFetchToolUI.tsx +53 -0
- package/src/chat/tool-ui/registry.ts +29 -0
- package/src/chat/tool-ui/types.ts +22 -0
- package/src/checkbox/Checkbox.tsx +50 -0
- package/src/codeblock/CodeBlock.tsx +76 -0
- package/src/codeblock/highlight.ts +159 -0
- package/src/colorpicker/ColorPicker.tsx +45 -0
- package/src/datepicker/DatePicker.tsx +481 -0
- package/src/desktop/DesktopHeader.tsx +92 -0
- package/src/desktop/DesktopIcon.tsx +34 -0
- package/src/desktop/DesktopStage.tsx +6 -0
- package/src/desktop/DesktopWindow.tsx +504 -0
- package/src/desktop/MeadowDesktop.tsx +15 -0
- package/src/desktop/MeadowShell.tsx +12 -0
- package/src/desktop/MeadowWallpaper.tsx +5 -0
- package/src/desktop/assets/desktop-meadow.jpg +0 -0
- package/src/desktop/assets.d.ts +9 -0
- package/src/desktop/surface.ts +1 -0
- package/src/drawer/Drawer.tsx +125 -0
- package/src/dropdown/ContextMenu.tsx +97 -0
- package/src/dropdown/Dropdown.tsx +139 -0
- package/src/dropdown/menuClasses.ts +9 -0
- package/src/empty/Empty.tsx +47 -0
- package/src/form/Form.tsx +95 -0
- package/src/form/FormItem.tsx +145 -0
- package/src/form/common/context.ts +13 -0
- package/src/form/common/enum.ts +20 -0
- package/src/form/common/rules.ts +68 -0
- package/src/form/common/types.ts +169 -0
- package/src/form/index.ts +35 -0
- package/src/form/partials/FormItemHelps.tsx +31 -0
- package/src/form/variants/FieldCheckbox.tsx +20 -0
- package/src/form/variants/FieldColor.tsx +63 -0
- package/src/form/variants/FieldDateTime.tsx +25 -0
- package/src/form/variants/FieldHidden.tsx +9 -0
- package/src/form/variants/FieldInput.tsx +24 -0
- package/src/form/variants/FieldNumber.tsx +28 -0
- package/src/form/variants/FieldObject.tsx +29 -0
- package/src/form/variants/FieldRadio.tsx +40 -0
- package/src/form/variants/FieldRepeater.tsx +62 -0
- package/src/form/variants/FieldRepeaterItem.tsx +64 -0
- package/src/form/variants/FieldSelect.tsx +27 -0
- package/src/form/variants/FieldSelectMultiple.tsx +32 -0
- package/src/form/variants/FieldSelectRemote.tsx +66 -0
- package/src/form/variants/FieldSwitch.tsx +17 -0
- package/src/form/variants/FieldTextarea.tsx +25 -0
- package/src/form/variants/FieldTime.tsx +25 -0
- package/src/form-layout/FormLayout.tsx +74 -0
- package/src/icon/FluentIcon.tsx +28 -0
- package/src/icon/fluent.ts +75 -0
- package/src/index.ts +214 -0
- package/src/input/Input.tsx +371 -0
- package/src/input/SearchInput.tsx +73 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/placement.ts +44 -0
- package/src/lib/sizes.ts +117 -0
- package/src/lib/surface.ts +8 -0
- package/src/menu/Menu.tsx +98 -0
- package/src/message/message.tsx +214 -0
- package/src/modal/Modal.tsx +277 -0
- package/src/pagination/Pagination.tsx +103 -0
- package/src/popconfirm/Popconfirm.tsx +134 -0
- package/src/popover/Popover.tsx +116 -0
- package/src/scroll/OverlayScroll.tsx +141 -0
- package/src/segmented/Segmented.tsx +67 -0
- package/src/select/Select.tsx +319 -0
- package/src/skeleton/Skeleton.tsx +47 -0
- package/src/spin/Spin.tsx +160 -0
- package/src/styles.css +897 -0
- package/src/switch/Switch.tsx +58 -0
- package/src/table/Table.tsx +387 -0
- package/src/tag/Tag.tsx +109 -0
- package/src/theme.ts +131 -0
- package/src/timepicker/TimePicker.tsx +751 -0
- package/src/tooltip/Tooltip.tsx +59 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { type ReactNode, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { FluentIcon } from "../../icon/FluentIcon";
|
|
3
|
+
import { cn } from "../../lib/cn";
|
|
4
|
+
import { formatBgElapsed, parseBgTaskRef } from "../common/bgTasks";
|
|
5
|
+
import { parseJsonObject, prettyJson, timestampMs } from "../common/utils";
|
|
6
|
+
import { ChatMarkdown } from "../message-ui/ChatMarkdown";
|
|
7
|
+
import { ChatSpinner } from "../message-ui/ChatSpinner";
|
|
8
|
+
import { ToolUiBadge } from "./ToolUiBadge";
|
|
9
|
+
import { ToolUiTrailing } from "./ToolUiTrailing";
|
|
10
|
+
import type { ToolUIProps } from "./types";
|
|
11
|
+
|
|
12
|
+
type CallAgentParsed = {
|
|
13
|
+
success: boolean;
|
|
14
|
+
response: string | null;
|
|
15
|
+
agentId: string | null;
|
|
16
|
+
error: string | null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function parseCallAgentOutput(raw: unknown): CallAgentParsed | null {
|
|
20
|
+
if (raw == null) return null;
|
|
21
|
+
const parsed = typeof raw === "string" ? parseJsonObject<Record<string, unknown>>(raw) : typeof raw === "object" ? (raw as Record<string, unknown>) : null;
|
|
22
|
+
if (!parsed || !("success" in parsed)) return null;
|
|
23
|
+
return {
|
|
24
|
+
success: Boolean(parsed.success),
|
|
25
|
+
response: (parsed.response as string | null | undefined) ?? null,
|
|
26
|
+
agentId: (parsed.agent_id as string | null | undefined) ?? null,
|
|
27
|
+
error: (parsed.error as string | null | undefined) ?? null,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function TypingDots() {
|
|
32
|
+
return (
|
|
33
|
+
<div className="flex items-center gap-1 px-0.5 py-0.5" aria-label="Typing">
|
|
34
|
+
{[0, 1, 2].map((i) => (
|
|
35
|
+
<span key={i} className="size-1.5 rounded-full bg-muted-foreground/50 animate-bounce" style={{ animationDelay: `${i * 150}ms` }} />
|
|
36
|
+
))}
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function AgentAvatar({ name, size = 22, className }: { name: string; size?: number; className?: string }) {
|
|
42
|
+
const initial = (name.trim()[0] ?? "?").toUpperCase();
|
|
43
|
+
return (
|
|
44
|
+
<div className={cn("flex items-center justify-center rounded-full bg-muted text-[10px] font-semibold text-muted-foreground", className)} style={{ width: size, height: size }} aria-hidden>
|
|
45
|
+
{initial}
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function AgentTurn({
|
|
51
|
+
name,
|
|
52
|
+
children,
|
|
53
|
+
align = "start",
|
|
54
|
+
}: {
|
|
55
|
+
name: string;
|
|
56
|
+
children: ReactNode;
|
|
57
|
+
align?: "start" | "end";
|
|
58
|
+
}) {
|
|
59
|
+
return (
|
|
60
|
+
<div className={cn("flex items-start gap-2", align === "end" && "flex-row-reverse")}>
|
|
61
|
+
<AgentAvatar name={name} className="mt-0.5 shrink-0" />
|
|
62
|
+
<div className={cn("flex min-w-0 flex-1 flex-col gap-0.5", align === "end" ? "items-end" : "items-start")}>
|
|
63
|
+
<span className="select-none text-[12px] font-medium text-tertiary-foreground">{name}</span>
|
|
64
|
+
<div className="w-fit max-w-[92%]">{children}</div>
|
|
65
|
+
</div>
|
|
66
|
+
</div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function ExpandableBody({ children, className }: { children: ReactNode; className?: string }) {
|
|
71
|
+
const [expanded, setExpanded] = useState(false);
|
|
72
|
+
const [overflows, setOverflows] = useState(false);
|
|
73
|
+
const bodyRef = useRef<HTMLDivElement>(null);
|
|
74
|
+
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
const el = bodyRef.current;
|
|
77
|
+
if (el) setOverflows(el.scrollHeight > 160);
|
|
78
|
+
}, [children]);
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<div>
|
|
82
|
+
<div ref={bodyRef} className={cn("overflow-hidden transition-[max-height] duration-200", !expanded && "max-h-40", className)}>
|
|
83
|
+
{children}
|
|
84
|
+
</div>
|
|
85
|
+
{overflows ? (
|
|
86
|
+
<button type="button" onClick={() => setExpanded((v) => !v)} className="mt-1 cursor-pointer border-0 bg-transparent p-0 text-[13px] text-tertiary-foreground hover:text-foreground">
|
|
87
|
+
{expanded ? "Show less" : "Show more"}
|
|
88
|
+
</button>
|
|
89
|
+
) : null}
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function CallAgentToolUI({ msg, assistantLabel = "Assistant", assistantColor, showAvatar = false }: ToolUIProps) {
|
|
95
|
+
const hasOutput = msg.toolOutput != null;
|
|
96
|
+
const hasError = Boolean(msg.toolError);
|
|
97
|
+
const bgRef = parseBgTaskRef(msg.toolOutput);
|
|
98
|
+
const parsed = parseCallAgentOutput(msg.toolOutput);
|
|
99
|
+
const failed = hasError || parsed?.success === false;
|
|
100
|
+
const bgRunning = Boolean(bgRef) && !failed && !parsed;
|
|
101
|
+
const [now, setNow] = useState(() => Date.now());
|
|
102
|
+
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (!bgRunning) return;
|
|
105
|
+
const id = setInterval(() => setNow(Date.now()), 1000);
|
|
106
|
+
return () => clearInterval(id);
|
|
107
|
+
}, [bgRunning]);
|
|
108
|
+
|
|
109
|
+
const requestMessage = ((msg.toolInput as Record<string, unknown> | null)?.message as string | undefined)?.trim() ?? "";
|
|
110
|
+
const hasRequest = requestMessage.length > 0;
|
|
111
|
+
const composing = !hasOutput && !hasError && !hasRequest;
|
|
112
|
+
const awaitingReply = bgRunning || (!hasOutput && !hasError && hasRequest);
|
|
113
|
+
const showCallee = awaitingReply || failed || parsed != null;
|
|
114
|
+
const calleeName = msg.toolLabel?.replace(/^Call\s+/i, "") ?? "Agent";
|
|
115
|
+
const statusLabel = failed && !awaitingReply ? (parsed?.error ?? "Call failed") : null;
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<div className="mt-1 animate-fadeIn">
|
|
119
|
+
<ToolUiBadge show={showAvatar} label={assistantLabel} color={assistantColor} />
|
|
120
|
+
<div className="px-4 py-1">
|
|
121
|
+
<div className={cn("overflow-hidden rounded-xl border bg-card", failed ? "border-destructive/35" : "border-border")}>
|
|
122
|
+
<div className="flex items-center gap-2 border-b border-border-subtle px-3 py-2">
|
|
123
|
+
<FluentIcon name="chat-24" size={13} className="shrink-0 text-muted-foreground" />
|
|
124
|
+
<span className="min-w-0 flex-1 truncate text-[14px] font-medium text-foreground">Calling {calleeName}</span>
|
|
125
|
+
{bgRunning ? <span className="text-[14px] tabular-nums text-muted-foreground">{formatBgElapsed(timestampMs(msg.timestamp), now)}</span> : null}
|
|
126
|
+
{statusLabel ? <span className="max-w-40 truncate text-[13px] italic text-destructive">{statusLabel}</span> : null}
|
|
127
|
+
<ToolUiTrailing running={composing || awaitingReply} failed={failed && !awaitingReply} />
|
|
128
|
+
</div>
|
|
129
|
+
|
|
130
|
+
{composing || hasRequest || showCallee ? (
|
|
131
|
+
<div className="flex flex-col gap-3 px-3 py-3">
|
|
132
|
+
{composing || hasRequest ? (
|
|
133
|
+
<AgentTurn name={assistantLabel} align="end">
|
|
134
|
+
<div className="rounded-2xl rounded-tr-sm border border-brand/40 bg-[color-mix(in_oklab,var(--brand)_14%,var(--card))] px-3 py-2 text-left">
|
|
135
|
+
{composing ? <TypingDots /> : <p className="m-0 text-[14px] leading-[1.55] whitespace-pre-wrap text-foreground">{requestMessage}</p>}
|
|
136
|
+
</div>
|
|
137
|
+
</AgentTurn>
|
|
138
|
+
) : null}
|
|
139
|
+
|
|
140
|
+
{showCallee ? (
|
|
141
|
+
<AgentTurn name={calleeName} align="start">
|
|
142
|
+
<div className={cn("w-fit rounded-2xl rounded-tl-sm border bg-transparent px-3 py-2 text-left", failed && !awaitingReply ? "border-destructive/35" : "border-border")}>
|
|
143
|
+
{awaitingReply ? (
|
|
144
|
+
<div className="flex items-center gap-1.5 py-0.5">
|
|
145
|
+
<ChatSpinner />
|
|
146
|
+
<span className="text-[14px] italic text-muted-foreground">Replying…</span>
|
|
147
|
+
</div>
|
|
148
|
+
) : failed ? (
|
|
149
|
+
<p className="m-0 text-[14px] leading-[1.55] text-destructive">{parsed?.error ?? "Agent call failed"}</p>
|
|
150
|
+
) : parsed ? (
|
|
151
|
+
<ExpandableBody>
|
|
152
|
+
<ChatMarkdown content={parsed.response ?? "(no response)"} className="[&_p]:m-0 [&_p]:mb-0 [&_p+p]:mt-2" />
|
|
153
|
+
</ExpandableBody>
|
|
154
|
+
) : (
|
|
155
|
+
<ExpandableBody>
|
|
156
|
+
<pre className="m-0 font-mono text-[14px] leading-normal break-all whitespace-pre-wrap text-muted-foreground">{prettyJson(msg.toolOutput)}</pre>
|
|
157
|
+
</ExpandableBody>
|
|
158
|
+
)}
|
|
159
|
+
</div>
|
|
160
|
+
</AgentTurn>
|
|
161
|
+
) : null}
|
|
162
|
+
</div>
|
|
163
|
+
) : null}
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { type ReactNode, useState } from "react";
|
|
2
|
+
import { cn } from "../../lib/cn";
|
|
3
|
+
import { formatToolName, hasMeaningfulInput, prettyJson } from "../common/utils";
|
|
4
|
+
import { ChatSpinner } from "../message-ui/ChatSpinner";
|
|
5
|
+
import { ToolUiTrailing } from "./ToolUiTrailing";
|
|
6
|
+
|
|
7
|
+
export type ChatToolCallProps = {
|
|
8
|
+
toolName?: string;
|
|
9
|
+
label?: string;
|
|
10
|
+
toolInput?: unknown;
|
|
11
|
+
toolOutput?: unknown;
|
|
12
|
+
toolError?: boolean | string;
|
|
13
|
+
/** Show running spinner (pending execution). */
|
|
14
|
+
running?: boolean;
|
|
15
|
+
/** Optional icon node (left of label). */
|
|
16
|
+
icon?: ReactNode;
|
|
17
|
+
className?: string;
|
|
18
|
+
defaultOpen?: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function ChatToolCall({ toolName = "Tool", label, toolInput, toolOutput, toolError, running = false, icon, className, defaultOpen = false }: ChatToolCallProps) {
|
|
22
|
+
const hasOutput = toolOutput != null;
|
|
23
|
+
const hasInput = hasMeaningfulInput(toolInput);
|
|
24
|
+
const hasError = Boolean(toolError);
|
|
25
|
+
const isPending = !hasOutput && !hasError;
|
|
26
|
+
const expandable = hasInput || hasOutput || hasError || running;
|
|
27
|
+
const [open, setOpen] = useState(defaultOpen);
|
|
28
|
+
const displayLabel = label ?? formatToolName(toolName);
|
|
29
|
+
|
|
30
|
+
const identityIcon = icon ?? (
|
|
31
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" className="shrink-0 text-muted-foreground" aria-hidden>
|
|
32
|
+
<path d="M8 7h8M8 12h8M8 17h5" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" />
|
|
33
|
+
<rect x="4" y="4" width="16" height="16" rx="3" stroke="currentColor" strokeWidth="1.75" />
|
|
34
|
+
</svg>
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
const header = (
|
|
38
|
+
<>
|
|
39
|
+
{identityIcon}
|
|
40
|
+
<span className={cn("min-w-0 flex-1 truncate text-left text-[14px] font-medium", running ? "nonla-chat-shimmer" : "text-muted-foreground", expandable && !running && "transition-colors group-hover:text-foreground")}>{displayLabel}</span>
|
|
41
|
+
<ToolUiTrailing running={running} failed={hasError} chevron={expandable} chevronClassName={cn("group-hover:opacity-100", open && "opacity-100 rotate-90")} />
|
|
42
|
+
</>
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className={cn("nonla-chat-tool mt-1", className)}>
|
|
47
|
+
<div className="px-4 pb-0.5">
|
|
48
|
+
<div className={cn("mb-1 overflow-hidden rounded-xl border", hasError ? "border-destructive/35" : "border-border")}>
|
|
49
|
+
{expandable ? (
|
|
50
|
+
<button type="button" onClick={() => setOpen((v) => !v)} className={cn("group flex w-full cursor-pointer items-center gap-2 px-2.5 py-1.5 outline-none transition-colors hover:bg-muted border-0 bg-transparent", open && "bg-muted")}>
|
|
51
|
+
{header}
|
|
52
|
+
</button>
|
|
53
|
+
) : (
|
|
54
|
+
<div className="flex w-full items-center gap-2 px-2.5 py-1.5">{header}</div>
|
|
55
|
+
)}
|
|
56
|
+
|
|
57
|
+
{open && expandable ? (
|
|
58
|
+
<div className="border-t border-border font-mono text-[14px]">
|
|
59
|
+
{hasInput ? <pre className="m-0 max-h-27.5 overflow-y-auto bg-transparent px-3 py-2 break-all whitespace-pre-wrap font-normal leading-[1.65] text-muted-foreground">{prettyJson(toolInput)}</pre> : null}
|
|
60
|
+
{running ? (
|
|
61
|
+
<div className="flex items-center gap-2 border-t border-border bg-muted/40 px-3 py-2 text-muted-foreground">
|
|
62
|
+
<ChatSpinner />
|
|
63
|
+
<span className="italic">Running…</span>
|
|
64
|
+
</div>
|
|
65
|
+
) : null}
|
|
66
|
+
{!isPending ? (
|
|
67
|
+
<pre className={cn("m-0 max-h-75 overflow-y-auto px-3 py-2 break-all whitespace-pre-wrap font-normal leading-[1.65] text-muted-foreground", hasInput && "border-t border-border", hasError ? "bg-destructive/6" : "bg-muted/40")}>
|
|
68
|
+
{hasOutput ? prettyJson(toolOutput) : typeof toolError === "string" ? toolError : "Tool execution failed"}
|
|
69
|
+
</pre>
|
|
70
|
+
) : null}
|
|
71
|
+
</div>
|
|
72
|
+
) : null}
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
</div>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { FluentIcon } from "../../icon/FluentIcon";
|
|
2
|
+
import { parseJsonObject } from "../common/utils";
|
|
3
|
+
import { ToolUiBadge } from "./ToolUiBadge";
|
|
4
|
+
import { ToolUiTrailing } from "./ToolUiTrailing";
|
|
5
|
+
import { isToolRunning, type ToolUIProps } from "./types";
|
|
6
|
+
|
|
7
|
+
type TimeResult = {
|
|
8
|
+
time?: string;
|
|
9
|
+
timezone?: string;
|
|
10
|
+
iso?: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
function formatLabel(iso: string | undefined, timezone: string | undefined, fallback: string | undefined): string {
|
|
14
|
+
if (iso) {
|
|
15
|
+
try {
|
|
16
|
+
const d = new Date(iso);
|
|
17
|
+
if (!Number.isNaN(d.getTime())) {
|
|
18
|
+
const tz = timezone || "UTC";
|
|
19
|
+
const clock = d.toLocaleTimeString("en-US", { timeZone: tz, hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false });
|
|
20
|
+
const date = d.toLocaleDateString("en-US", { timeZone: tz, weekday: "short", month: "short", day: "numeric" });
|
|
21
|
+
return `${clock} · ${date} · ${tz}`;
|
|
22
|
+
}
|
|
23
|
+
} catch {
|
|
24
|
+
/* fall through */
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
if (fallback && timezone) return `${fallback} · ${timezone}`;
|
|
28
|
+
return fallback ?? timezone ?? "—";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function GetCurrentTimeToolUI({ msg, assistantLabel = "Assistant", assistantColor, showAvatar = false, generating = false }: ToolUIProps) {
|
|
32
|
+
const hasError = Boolean(msg.toolError);
|
|
33
|
+
const output = parseJsonObject<TimeResult>(msg.toolOutput);
|
|
34
|
+
const running = isToolRunning(msg, generating);
|
|
35
|
+
const label = formatLabel(output?.iso, output?.timezone, output?.time);
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className="mt-1 animate-fadeIn">
|
|
39
|
+
<ToolUiBadge show={showAvatar} label={assistantLabel} color={assistantColor} />
|
|
40
|
+
<div className="px-4 pb-1">
|
|
41
|
+
<div className="flex items-center gap-2 py-1">
|
|
42
|
+
<FluentIcon name="clock-24" size={13} className="shrink-0 text-muted-foreground" />
|
|
43
|
+
<span className="truncate text-[14px] font-medium tabular-nums text-muted-foreground">{hasError ? "Failed to get time" : running ? "Getting time…" : label}</span>
|
|
44
|
+
<ToolUiTrailing running={running} failed={hasError} />
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { FluentIcon } from "../../icon/FluentIcon";
|
|
2
|
+
import { cn } from "../../lib/cn";
|
|
3
|
+
import { parseJsonObject } from "../common/utils";
|
|
4
|
+
import { ToolUiBadge } from "./ToolUiBadge";
|
|
5
|
+
import { ToolUiTrailing } from "./ToolUiTrailing";
|
|
6
|
+
import { isToolRunning, type ToolUIProps } from "./types";
|
|
7
|
+
|
|
8
|
+
type ReadSkillInput = { name?: string; reference?: string };
|
|
9
|
+
type SkillRef = { name?: string; title?: string };
|
|
10
|
+
type ReadSkillOutput = {
|
|
11
|
+
ok?: boolean;
|
|
12
|
+
error?: string;
|
|
13
|
+
skill?: string;
|
|
14
|
+
reference?: string;
|
|
15
|
+
title?: string;
|
|
16
|
+
description?: string;
|
|
17
|
+
content?: string;
|
|
18
|
+
references?: SkillRef[];
|
|
19
|
+
available_references?: SkillRef[];
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function refNames(list?: SkillRef[]): string[] {
|
|
23
|
+
if (!Array.isArray(list)) return [];
|
|
24
|
+
return list.map((r) => (typeof r.name === "string" ? r.name.trim() : "")).filter(Boolean);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function ReadSkillToolUI({ msg, assistantLabel = "Assistant", assistantColor, showAvatar = false, generating = false }: ToolUIProps) {
|
|
28
|
+
const hasError = Boolean(msg.toolError);
|
|
29
|
+
const input = parseJsonObject<ReadSkillInput>(msg.toolInput) ?? {};
|
|
30
|
+
const output = parseJsonObject<ReadSkillOutput>(msg.toolOutput);
|
|
31
|
+
const failed = hasError || output?.ok === false;
|
|
32
|
+
const running = isToolRunning(msg, generating);
|
|
33
|
+
|
|
34
|
+
const skillName = (output?.skill || input.name || "").trim();
|
|
35
|
+
const reference = (output?.reference || input.reference || "").trim();
|
|
36
|
+
const available = refNames(output?.references ?? output?.available_references);
|
|
37
|
+
const content = output?.content?.trim() || "";
|
|
38
|
+
const body = failed ? (output?.error ?? (typeof msg.toolError === "string" ? msg.toolError : null) ?? "Failed to read skill") : content;
|
|
39
|
+
|
|
40
|
+
const verb = (() => {
|
|
41
|
+
if (failed) return "Failed to read skill";
|
|
42
|
+
if (running) return reference ? "Reading ref" : "Reading skill";
|
|
43
|
+
return reference ? "Read skill ref" : "Read skill";
|
|
44
|
+
})();
|
|
45
|
+
|
|
46
|
+
const targetLabel = (() => {
|
|
47
|
+
if (!skillName && !reference) return null;
|
|
48
|
+
if (reference) return skillName ? `${skillName} / ${reference}` : reference;
|
|
49
|
+
return skillName || null;
|
|
50
|
+
})();
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div className="mt-1 animate-fadeIn">
|
|
54
|
+
<ToolUiBadge show={showAvatar} label={assistantLabel} color={assistantColor} />
|
|
55
|
+
<details className="group/readskill px-4 pb-2" style={{ overflowAnchor: "none" }}>
|
|
56
|
+
<summary className="flex cursor-pointer list-none items-center gap-2 py-0.5 text-[14px] leading-5.5 select-none [&::-webkit-details-marker]:hidden">
|
|
57
|
+
<FluentIcon name={reference ? "document-text-24" : "book-24"} size={13} className="shrink-0 text-muted-foreground" />
|
|
58
|
+
<span className={cn("min-w-0 truncate font-medium text-muted-foreground", running && "nonla-chat-shimmer")}>
|
|
59
|
+
{verb}
|
|
60
|
+
{targetLabel ? <span className="font-normal text-tertiary-foreground"> {targetLabel}</span> : null}
|
|
61
|
+
</span>
|
|
62
|
+
<ToolUiTrailing running={running} failed={failed} chevron chevronClassName="group-hover/readskill:opacity-100 group-open/readskill:opacity-100 group-open/readskill:rotate-90" />
|
|
63
|
+
</summary>
|
|
64
|
+
{body ? (
|
|
65
|
+
<pre className={cn("m-0 mt-1.5 mb-1 max-h-40 overflow-y-auto rounded-lg border px-3 py-2 font-mono text-[14px] font-normal leading-[1.65] break-all whitespace-pre-wrap", failed ? "border-destructive/35 bg-destructive/6 text-destructive" : "border-(--popper-border) bg-(--nonla-elevated) text-muted-foreground")}>{body}</pre>
|
|
66
|
+
) : null}
|
|
67
|
+
{!running && !reference && available.length > 0 ? (
|
|
68
|
+
<div className="mt-0.5 mb-1 flex flex-wrap items-center gap-1">
|
|
69
|
+
<span className="text-[10px] font-medium tracking-wide text-quaternary-foreground uppercase">Refs</span>
|
|
70
|
+
{available.map((name) => (
|
|
71
|
+
<span key={name} className="inline-flex max-w-full items-center rounded-md border border-border-subtle bg-muted/40 px-1.5 py-0.5 font-mono text-[14px] leading-[1.4] text-tertiary-foreground">
|
|
72
|
+
{skillName ? `${skillName} / ${name}` : name}
|
|
73
|
+
</span>
|
|
74
|
+
))}
|
|
75
|
+
</div>
|
|
76
|
+
) : null}
|
|
77
|
+
</details>
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { FluentIcon } from "../../icon/FluentIcon";
|
|
2
|
+
import { cn } from "../../lib/cn";
|
|
3
|
+
import { CodeBlock } from "../../codeblock/CodeBlock";
|
|
4
|
+
import { parseBgTaskRef } from "../common/bgTasks";
|
|
5
|
+
import { parseJsonObject, prettyJson } from "../common/utils";
|
|
6
|
+
import { ChatSpinner } from "../message-ui/ChatSpinner";
|
|
7
|
+
import { BackgroundTaskToolUI } from "./BackgroundTaskToolUI";
|
|
8
|
+
import { ToolUiBadge } from "./ToolUiBadge";
|
|
9
|
+
import { ToolUiTrailing } from "./ToolUiTrailing";
|
|
10
|
+
import { isToolRunning, type ToolUIProps } from "./types";
|
|
11
|
+
|
|
12
|
+
type JsInput = { code?: string };
|
|
13
|
+
type JsOutput = {
|
|
14
|
+
ok?: boolean;
|
|
15
|
+
result?: unknown;
|
|
16
|
+
error?: string;
|
|
17
|
+
console?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function extractCode(input: unknown): string {
|
|
21
|
+
const parsed = parseJsonObject<JsInput>(input);
|
|
22
|
+
if (parsed?.code && typeof parsed.code === "string") return parsed.code;
|
|
23
|
+
if (typeof input === "string") return input;
|
|
24
|
+
return "";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function resultText(value: unknown): string {
|
|
28
|
+
if (typeof value === "string") return value;
|
|
29
|
+
if (value === undefined) return "undefined";
|
|
30
|
+
if (typeof value === "number" || typeof value === "boolean" || value === null) return String(value);
|
|
31
|
+
return prettyJson(value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function SandboxOutput({
|
|
35
|
+
running,
|
|
36
|
+
failed,
|
|
37
|
+
consoleOut,
|
|
38
|
+
result,
|
|
39
|
+
}: {
|
|
40
|
+
running: boolean;
|
|
41
|
+
failed: boolean;
|
|
42
|
+
consoleOut: string | null;
|
|
43
|
+
result: unknown;
|
|
44
|
+
}) {
|
|
45
|
+
if (running) {
|
|
46
|
+
return (
|
|
47
|
+
<div className="flex items-center gap-2 border-t border-border px-3 py-2 text-[13px] text-muted-foreground">
|
|
48
|
+
<ChatSpinner />
|
|
49
|
+
<span>Running…</span>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className={cn("max-h-40 overflow-y-auto border-t px-3 py-2 font-mono text-[13px] leading-[1.65]", failed ? "border-destructive/35 bg-destructive/6" : "border-border")}>
|
|
56
|
+
{consoleOut ? <pre className="m-0 whitespace-pre-wrap break-all text-tertiary-foreground">{consoleOut}</pre> : null}
|
|
57
|
+
{failed ? (
|
|
58
|
+
<pre className={cn("m-0 whitespace-pre-wrap break-all text-destructive", consoleOut && "mt-1")}>{resultText(result)}</pre>
|
|
59
|
+
) : (
|
|
60
|
+
<div className={cn("flex gap-1.5 whitespace-pre-wrap break-all text-foreground", consoleOut && "mt-1")}>
|
|
61
|
+
<span className="shrink-0 select-none text-quaternary-foreground">{"=>"}</span>
|
|
62
|
+
<span>{resultText(result)}</span>
|
|
63
|
+
</div>
|
|
64
|
+
)}
|
|
65
|
+
</div>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function RunJsToolUI({ msg, assistantLabel = "Assistant", assistantColor, showAvatar = false, generating = false }: ToolUIProps) {
|
|
70
|
+
if (parseBgTaskRef(msg.toolOutput)) {
|
|
71
|
+
return <BackgroundTaskToolUI msg={msg} assistantLabel={assistantLabel} assistantColor={assistantColor} showAvatar={showAvatar} />;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const hasError = Boolean(msg.toolError);
|
|
75
|
+
const code = extractCode(msg.toolInput);
|
|
76
|
+
const output = parseJsonObject<JsOutput>(msg.toolOutput);
|
|
77
|
+
const running = isToolRunning(msg, generating);
|
|
78
|
+
const failed = hasError || output?.ok === false;
|
|
79
|
+
const consoleOut = output?.console?.trim() || null;
|
|
80
|
+
const resultBody = failed ? (output?.error ?? (typeof msg.toolError === "string" ? msg.toolError : null) ?? "Execution failed") : output && "result" in output ? output.result : undefined;
|
|
81
|
+
const showOutput = running || failed || consoleOut != null || (output != null && "result" in output);
|
|
82
|
+
const verb = failed ? "JS failed" : running ? "Running JS" : "Ran JS";
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className="mt-1 animate-fadeIn">
|
|
86
|
+
<ToolUiBadge show={showAvatar} label={assistantLabel} color={assistantColor} />
|
|
87
|
+
<details className="group/runjs px-4 pb-2" style={{ overflowAnchor: "none" }}>
|
|
88
|
+
<summary className="flex cursor-pointer list-none items-center gap-2 py-0.5 text-[14px] leading-5.5 select-none [&::-webkit-details-marker]:hidden">
|
|
89
|
+
<FluentIcon name="code-24" size={13} className="shrink-0 text-muted-foreground" />
|
|
90
|
+
<span className={cn("min-w-0 truncate font-medium text-muted-foreground", running && "nonla-chat-shimmer")}>{verb}</span>
|
|
91
|
+
<ToolUiTrailing running={running} failed={failed} chevron chevronClassName="group-hover/runjs:opacity-100 group-open/runjs:opacity-100 group-open/runjs:rotate-90" />
|
|
92
|
+
</summary>
|
|
93
|
+
|
|
94
|
+
{code ? (
|
|
95
|
+
<CodeBlock code={code} language="javascript" title="JS" className={cn("mt-1.5 mb-1", failed && "border-destructive/35")}>
|
|
96
|
+
{showOutput ? <SandboxOutput running={running} failed={failed} consoleOut={consoleOut} result={resultBody} /> : null}
|
|
97
|
+
</CodeBlock>
|
|
98
|
+
) : showOutput ? (
|
|
99
|
+
<div className={cn("mt-1.5 mb-1 overflow-hidden rounded-lg border", failed ? "border-destructive/35" : "border-(--popper-border)")}>
|
|
100
|
+
<SandboxOutput running={running} failed={failed} consoleOut={consoleOut} result={resultBody} />
|
|
101
|
+
</div>
|
|
102
|
+
) : null}
|
|
103
|
+
</details>
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function ToolUiBadge({ show = false, label, color }: { show?: boolean; label: string; color?: string | null }) {
|
|
2
|
+
if (!show) return null;
|
|
3
|
+
return (
|
|
4
|
+
<div className="flex items-center gap-2.5 px-4 pt-3 pb-1">
|
|
5
|
+
<span className="inline-flex items-center rounded px-1.5 py-0.5 text-[10px] font-bold tracking-wider uppercase select-none" style={{ background: color ?? "var(--primary)", color: "var(--primary-foreground)", letterSpacing: "0.08em" }}>
|
|
6
|
+
{label}
|
|
7
|
+
</span>
|
|
8
|
+
</div>
|
|
9
|
+
);
|
|
10
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { FluentIcon } from "../../icon/FluentIcon";
|
|
2
|
+
import { cn } from "../../lib/cn";
|
|
3
|
+
import { ChatSpinner } from "../message-ui/ChatSpinner";
|
|
4
|
+
|
|
5
|
+
export function ToolUiTrailing({
|
|
6
|
+
running = false,
|
|
7
|
+
failed = false,
|
|
8
|
+
chevron = false,
|
|
9
|
+
chevronClassName,
|
|
10
|
+
}: {
|
|
11
|
+
running?: boolean;
|
|
12
|
+
failed?: boolean;
|
|
13
|
+
chevron?: boolean;
|
|
14
|
+
chevronClassName?: string;
|
|
15
|
+
}) {
|
|
16
|
+
if (running) return <ChatSpinner />;
|
|
17
|
+
if (failed) return <FluentIcon name="error-circle-24" size={13} className="text-destructive" />;
|
|
18
|
+
if (!chevron) return null;
|
|
19
|
+
return (
|
|
20
|
+
<svg className={cn("h-3 w-3 shrink-0 opacity-0 transition-[opacity,transform] duration-150", chevronClassName)} fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden>
|
|
21
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
22
|
+
</svg>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { FluentIcon } from "../../icon/FluentIcon";
|
|
2
|
+
import { cn } from "../../lib/cn";
|
|
3
|
+
import { parseJsonObject } from "../common/utils";
|
|
4
|
+
import { ToolUiBadge } from "./ToolUiBadge";
|
|
5
|
+
import { ToolUiTrailing } from "./ToolUiTrailing";
|
|
6
|
+
import { isToolRunning, type ToolUIProps } from "./types";
|
|
7
|
+
|
|
8
|
+
type FetchInput = {
|
|
9
|
+
url?: string;
|
|
10
|
+
actions?: { action?: string; url?: string }[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type FetchOutput = {
|
|
14
|
+
ok?: boolean;
|
|
15
|
+
url?: string;
|
|
16
|
+
text?: string;
|
|
17
|
+
error?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function inputUrl(input: unknown): string {
|
|
21
|
+
const rec = parseJsonObject<FetchInput>(input);
|
|
22
|
+
if (rec?.url) return rec.url;
|
|
23
|
+
const nav = rec?.actions?.find((a) => a.action === "navigate" && a.url);
|
|
24
|
+
return nav?.url || "";
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function WebFetchToolUI({ msg, assistantLabel = "Assistant", assistantColor, showAvatar = false, generating = false }: ToolUIProps) {
|
|
28
|
+
const hasError = Boolean(msg.toolError);
|
|
29
|
+
const output = parseJsonObject<FetchOutput>(msg.toolOutput);
|
|
30
|
+
const failed = hasError || output?.ok === false;
|
|
31
|
+
const running = isToolRunning(msg, generating);
|
|
32
|
+
const url = output?.url || inputUrl(msg.toolInput) || "—";
|
|
33
|
+
const body = failed ? (output?.error ?? (typeof msg.toolError === "string" ? msg.toolError : null) ?? "Tool execution failed") : output?.text?.trim() || "";
|
|
34
|
+
const verb = running || failed ? "Fetch page" : "Fetched page";
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div className="mt-1 animate-fadeIn">
|
|
38
|
+
<ToolUiBadge show={showAvatar} label={assistantLabel} color={assistantColor} />
|
|
39
|
+
<details className="group/webfetch px-4 pb-2" style={{ overflowAnchor: "none" }}>
|
|
40
|
+
<summary className="flex cursor-pointer list-none items-center gap-2 py-0.5 text-[14px] leading-5.5 select-none [&::-webkit-details-marker]:hidden">
|
|
41
|
+
<FluentIcon name="globe-24" size={13} className="shrink-0 text-muted-foreground" />
|
|
42
|
+
<span className={cn("min-w-0 truncate font-medium text-muted-foreground", running && "nonla-chat-shimmer")}>
|
|
43
|
+
{verb} <span className="font-normal text-tertiary-foreground">{url}</span>
|
|
44
|
+
</span>
|
|
45
|
+
<ToolUiTrailing running={running} failed={failed} chevron chevronClassName="group-hover/webfetch:opacity-100 group-open/webfetch:opacity-100 group-open/webfetch:rotate-90" />
|
|
46
|
+
</summary>
|
|
47
|
+
{body ? (
|
|
48
|
+
<pre className={cn("m-0 mt-1.5 mb-1 max-h-40 overflow-y-auto rounded-lg border px-3 py-2 font-mono text-[14px] font-normal leading-[1.65] break-all whitespace-pre-wrap", failed ? "border-destructive/35 bg-destructive/6 text-destructive" : "border-(--popper-border) bg-(--nonla-elevated) text-muted-foreground")}>{body}</pre>
|
|
49
|
+
) : null}
|
|
50
|
+
</details>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ComponentType } from "react";
|
|
2
|
+
import { isCallAgentToolName } from "../common/utils";
|
|
3
|
+
import { CallAgentToolUI } from "./CallAgentToolUI";
|
|
4
|
+
import { GetCurrentTimeToolUI } from "./GetCurrentTimeToolUI";
|
|
5
|
+
import { ReadSkillToolUI } from "./ReadSkillToolUI";
|
|
6
|
+
import { RunJsToolUI } from "./RunJsToolUI";
|
|
7
|
+
import type { ToolUIProps } from "./types";
|
|
8
|
+
import { WebFetchToolUI } from "./WebFetchToolUI";
|
|
9
|
+
|
|
10
|
+
type ToolUIEntry = {
|
|
11
|
+
match: (toolName: string) => boolean;
|
|
12
|
+
component: ComponentType<ToolUIProps>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const TOOL_UIS: ToolUIEntry[] = [
|
|
16
|
+
{ match: isCallAgentToolName, component: CallAgentToolUI },
|
|
17
|
+
{ match: (n) => n === "web_fetch" || n === "fetch_url" || n === "browser", component: WebFetchToolUI },
|
|
18
|
+
{ match: (n) => n === "get_current_time", component: GetCurrentTimeToolUI },
|
|
19
|
+
{ match: (n) => n === "read_skill", component: ReadSkillToolUI },
|
|
20
|
+
{ match: (n) => n === "run_js", component: RunJsToolUI },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export function resolveToolUI(toolName: string | null | undefined): ComponentType<ToolUIProps> | null {
|
|
24
|
+
if (!toolName) return null;
|
|
25
|
+
for (const entry of TOOL_UIS) {
|
|
26
|
+
if (entry.match(toolName)) return entry.component;
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type ChatToolMessage = {
|
|
2
|
+
id?: string;
|
|
3
|
+
toolCallId?: string;
|
|
4
|
+
toolName?: string;
|
|
5
|
+
toolLabel?: string;
|
|
6
|
+
toolInput?: unknown;
|
|
7
|
+
toolOutput?: unknown;
|
|
8
|
+
toolError?: boolean | string;
|
|
9
|
+
timestamp?: Date | string | number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type ToolUIProps = {
|
|
13
|
+
msg: ChatToolMessage;
|
|
14
|
+
assistantLabel?: string;
|
|
15
|
+
assistantColor?: string | null;
|
|
16
|
+
showAvatar?: boolean;
|
|
17
|
+
generating?: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function isToolRunning(msg: ChatToolMessage, generating?: boolean) {
|
|
21
|
+
return msg.toolOutput == null && !msg.toolError && Boolean(generating);
|
|
22
|
+
}
|