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,60 @@
|
|
|
1
|
+
import { type ComponentProps, useEffect, useState } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
|
|
4
|
+
export type ButtonCopyProps = Omit<ComponentProps<"button">, "children"> & {
|
|
5
|
+
text?: string;
|
|
6
|
+
getText?: () => string;
|
|
7
|
+
/** Idle tooltip / aria-label. Copied state uses "Copied". */
|
|
8
|
+
label?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
function CopyIcon({ size = 14 }: { size?: number }) {
|
|
12
|
+
return (
|
|
13
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
|
14
|
+
<rect x="8" y="8" width="14" height="14" rx="2" stroke="currentColor" strokeWidth="2" />
|
|
15
|
+
<path d="M4 16V4c0-1.1.9-2 2-2h10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
|
|
16
|
+
</svg>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function CheckIcon({ size = 14 }: { size?: number }) {
|
|
21
|
+
return (
|
|
22
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden>
|
|
23
|
+
<path d="M5 13l4 4L19 7" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
24
|
+
</svg>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function ButtonCopy({ text, getText, label = "Copy", className, onClick, ...props }: ButtonCopyProps) {
|
|
29
|
+
const [copied, setCopied] = useState(false);
|
|
30
|
+
|
|
31
|
+
useEffect(() => {
|
|
32
|
+
if (!copied) return;
|
|
33
|
+
const t = window.setTimeout(() => setCopied(false), 1600);
|
|
34
|
+
return () => window.clearTimeout(t);
|
|
35
|
+
}, [copied]);
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<button
|
|
39
|
+
type="button"
|
|
40
|
+
{...props}
|
|
41
|
+
title={copied ? "Copied" : label}
|
|
42
|
+
aria-label={copied ? "Copied" : label}
|
|
43
|
+
className={cn("inline-flex size-7 cursor-pointer shrink-0 items-center justify-center text-muted-foreground transition-colors duration-200 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", className)}
|
|
44
|
+
onClick={async (e) => {
|
|
45
|
+
onClick?.(e);
|
|
46
|
+
if (e.defaultPrevented) return;
|
|
47
|
+
const value = getText?.() ?? text ?? "";
|
|
48
|
+
if (!value) return;
|
|
49
|
+
try {
|
|
50
|
+
await navigator.clipboard.writeText(value);
|
|
51
|
+
setCopied(true);
|
|
52
|
+
} catch {
|
|
53
|
+
/* ignore */
|
|
54
|
+
}
|
|
55
|
+
}}
|
|
56
|
+
>
|
|
57
|
+
{copied ? <CheckIcon /> : <CopyIcon />}
|
|
58
|
+
</button>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { type CSSProperties, useEffect, useRef } from "react";
|
|
2
|
+
import { DayPicker, getDefaultClassNames, type DayButtonProps, type DayPickerProps } from "react-day-picker";
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
|
|
5
|
+
export type CalendarProps = DayPickerProps;
|
|
6
|
+
|
|
7
|
+
function Chevron({
|
|
8
|
+
className,
|
|
9
|
+
orientation,
|
|
10
|
+
size = 16,
|
|
11
|
+
...props
|
|
12
|
+
}: {
|
|
13
|
+
className?: string;
|
|
14
|
+
orientation?: "left" | "right" | "up" | "down";
|
|
15
|
+
size?: number;
|
|
16
|
+
disabled?: boolean;
|
|
17
|
+
style?: CSSProperties;
|
|
18
|
+
}) {
|
|
19
|
+
const d =
|
|
20
|
+
orientation === "left"
|
|
21
|
+
? "M15 18L9 12L15 6"
|
|
22
|
+
: orientation === "right"
|
|
23
|
+
? "M9 18L15 12L9 6"
|
|
24
|
+
: orientation === "up"
|
|
25
|
+
? "M6 15L12 9L18 15"
|
|
26
|
+
: "M6 9L12 15L18 9";
|
|
27
|
+
return (
|
|
28
|
+
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" aria-hidden className={cn("opacity-70", className)} {...props}>
|
|
29
|
+
<path d={d} stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
|
|
30
|
+
</svg>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function CalendarDayButton({ className, day, modifiers, children, ...props }: DayButtonProps) {
|
|
35
|
+
const defaultClassNames = getDefaultClassNames();
|
|
36
|
+
const ref = useRef<HTMLButtonElement>(null);
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (modifiers.focused) ref.current?.focus();
|
|
39
|
+
}, [modifiers.focused]);
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<button
|
|
43
|
+
ref={ref}
|
|
44
|
+
type="button"
|
|
45
|
+
{...props}
|
|
46
|
+
data-day={day.date.toLocaleDateString()}
|
|
47
|
+
data-today={modifiers.today ? "true" : undefined}
|
|
48
|
+
data-selected-single={modifiers.selected && !modifiers.range_start && !modifiers.range_end && !modifiers.range_middle ? "true" : undefined}
|
|
49
|
+
data-range-start={modifiers.range_start ? "true" : undefined}
|
|
50
|
+
data-range-end={modifiers.range_end ? "true" : undefined}
|
|
51
|
+
data-range-middle={modifiers.range_middle ? "true" : undefined}
|
|
52
|
+
className={cn(
|
|
53
|
+
"relative flex aspect-square size-full min-w-8 items-center justify-center rounded-md bg-transparent text-sm font-normal leading-none",
|
|
54
|
+
"transition-colors hover:bg-brand-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
55
|
+
"data-[selected-single=true]:bg-brand-200 data-[selected-single=true]:text-foreground data-[selected-single=true]:hover:bg-brand-200",
|
|
56
|
+
"data-[range-start=true]:bg-brand-200 data-[range-start=true]:text-foreground data-[range-start=true]:hover:bg-brand-200 data-[range-start=true]:rounded-md",
|
|
57
|
+
"data-[range-end=true]:bg-brand-200 data-[range-end=true]:text-foreground data-[range-end=true]:hover:bg-brand-200 data-[range-end=true]:rounded-md",
|
|
58
|
+
"data-[range-middle=true]:bg-brand-50 data-[range-middle=true]:text-foreground data-[range-middle=true]:rounded-none",
|
|
59
|
+
defaultClassNames.day_button,
|
|
60
|
+
className,
|
|
61
|
+
)}
|
|
62
|
+
>
|
|
63
|
+
{children}
|
|
64
|
+
{modifiers.today ? <span aria-hidden className="absolute bottom-1 left-1/2 size-1 -translate-x-1/2 rounded-full bg-brand" /> : null}
|
|
65
|
+
</button>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function Calendar({
|
|
70
|
+
className,
|
|
71
|
+
classNames,
|
|
72
|
+
showOutsideDays = true,
|
|
73
|
+
captionLayout = "label",
|
|
74
|
+
components,
|
|
75
|
+
formatters,
|
|
76
|
+
...props
|
|
77
|
+
}: CalendarProps) {
|
|
78
|
+
const defaultClassNames = getDefaultClassNames();
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<DayPicker
|
|
82
|
+
showOutsideDays={showOutsideDays}
|
|
83
|
+
captionLayout={captionLayout}
|
|
84
|
+
className={cn("group/calendar w-fit bg-transparent p-0 [--cell-size:2.25rem]", className)}
|
|
85
|
+
formatters={{
|
|
86
|
+
formatMonthDropdown: (date) => date.toLocaleString("default", { month: "short" }),
|
|
87
|
+
...formatters,
|
|
88
|
+
}}
|
|
89
|
+
classNames={{
|
|
90
|
+
root: cn("w-fit", defaultClassNames.root),
|
|
91
|
+
months: cn("relative flex flex-col gap-4 sm:flex-row sm:gap-6", defaultClassNames.months),
|
|
92
|
+
month: cn("flex w-full flex-col gap-3", defaultClassNames.month),
|
|
93
|
+
nav: cn("absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", defaultClassNames.nav),
|
|
94
|
+
button_previous: cn(
|
|
95
|
+
"inline-flex size-[var(--cell-size)] items-center justify-center rounded-md p-0 text-foreground select-none",
|
|
96
|
+
"hover:bg-brand-50 disabled:opacity-40",
|
|
97
|
+
defaultClassNames.button_previous,
|
|
98
|
+
),
|
|
99
|
+
button_next: cn(
|
|
100
|
+
"inline-flex size-[var(--cell-size)] items-center justify-center rounded-md p-0 text-foreground select-none",
|
|
101
|
+
"hover:bg-brand-50 disabled:opacity-40",
|
|
102
|
+
defaultClassNames.button_next,
|
|
103
|
+
),
|
|
104
|
+
month_caption: cn("flex h-[var(--cell-size)] w-full items-center justify-center px-[var(--cell-size)]", defaultClassNames.month_caption),
|
|
105
|
+
caption_label: cn("select-none text-sm font-medium text-foreground", defaultClassNames.caption_label),
|
|
106
|
+
dropdowns: cn("flex h-[var(--cell-size)] w-full items-center justify-center gap-1.5 text-sm font-medium", defaultClassNames.dropdowns),
|
|
107
|
+
month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
|
|
108
|
+
weekdays: cn("flex", defaultClassNames.weekdays),
|
|
109
|
+
weekday: cn("flex-1 select-none rounded-md text-[0.8rem] font-normal text-muted-foreground", defaultClassNames.weekday),
|
|
110
|
+
week: cn("mt-1.5 flex w-full", defaultClassNames.week),
|
|
111
|
+
day: cn(
|
|
112
|
+
"group/day relative aspect-square h-full w-full p-0 text-center select-none",
|
|
113
|
+
"[&:first-child[data-selected=true]_button]:rounded-l-md [&:last-child[data-selected=true]_button]:rounded-r-md",
|
|
114
|
+
defaultClassNames.day,
|
|
115
|
+
),
|
|
116
|
+
today: cn(defaultClassNames.today),
|
|
117
|
+
outside: cn("text-muted-foreground/60 aria-selected:text-muted-foreground", defaultClassNames.outside),
|
|
118
|
+
disabled: cn("text-muted-foreground opacity-40", defaultClassNames.disabled),
|
|
119
|
+
hidden: cn("invisible", defaultClassNames.hidden),
|
|
120
|
+
range_start: cn("rounded-l-md bg-brand-50", defaultClassNames.range_start),
|
|
121
|
+
range_middle: cn("rounded-none bg-brand-50", defaultClassNames.range_middle),
|
|
122
|
+
range_end: cn("rounded-r-md bg-brand-50", defaultClassNames.range_end),
|
|
123
|
+
...classNames,
|
|
124
|
+
}}
|
|
125
|
+
components={{
|
|
126
|
+
Chevron,
|
|
127
|
+
DayButton: CalendarDayButton,
|
|
128
|
+
...components,
|
|
129
|
+
}}
|
|
130
|
+
{...props}
|
|
131
|
+
/>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
import { OverlayScroll, type OverlayScrollVisibility } from "../scroll/OverlayScroll";
|
|
4
|
+
import type { AgentMessage, AgentPanelEndpoint, AgentToolAction } from "./common/types";
|
|
5
|
+
import { parseBgTaskRef } from "./common/bgTasks";
|
|
6
|
+
import { useAgentStream } from "./common/useAgentStream";
|
|
7
|
+
import { formatToolName } from "./common/utils";
|
|
8
|
+
import { ChatAgentMessage } from "./message-ui/ChatAgentMessage";
|
|
9
|
+
import { ChatError } from "./message-ui/ChatError";
|
|
10
|
+
import { ChatInput } from "./message-ui/ChatInput";
|
|
11
|
+
import { ChatMarkdown } from "./message-ui/ChatMarkdown";
|
|
12
|
+
import { ChatThinking } from "./message-ui/ChatThinking";
|
|
13
|
+
import { ChatUserMessage } from "./message-ui/ChatUserMessage";
|
|
14
|
+
import { ChatWelcome } from "./message-ui/ChatWelcome";
|
|
15
|
+
import { BackgroundTaskToolUI } from "./tool-ui/BackgroundTaskToolUI";
|
|
16
|
+
import { ChatToolCall } from "./tool-ui/ChatToolCall";
|
|
17
|
+
import { resolveToolUI } from "./tool-ui/registry";
|
|
18
|
+
|
|
19
|
+
export type AgentPanelProps = {
|
|
20
|
+
/** POST URL, or a function that returns an SSE `Response`. */
|
|
21
|
+
endpoint: AgentPanelEndpoint;
|
|
22
|
+
title?: string;
|
|
23
|
+
/** Welcome name. Falls back to `title`. */
|
|
24
|
+
name?: string;
|
|
25
|
+
description?: string;
|
|
26
|
+
avatar?: ReactNode;
|
|
27
|
+
starters?: string[];
|
|
28
|
+
placeholder?: string;
|
|
29
|
+
/** Extra JSON merged into the POST body (string endpoint only). */
|
|
30
|
+
extraBody?: Record<string, unknown> | (() => Record<string, unknown>);
|
|
31
|
+
headers?: Record<string, string>;
|
|
32
|
+
fetcher?: typeof fetch;
|
|
33
|
+
initialMessages?: AgentMessage[];
|
|
34
|
+
toolbar?: ReactNode;
|
|
35
|
+
accessory?: ReactNode;
|
|
36
|
+
emptyState?: ReactNode;
|
|
37
|
+
/** Overlay scrollbar — does not reserve layout space. */
|
|
38
|
+
scrollbar?: OverlayScrollVisibility;
|
|
39
|
+
className?: string;
|
|
40
|
+
onToolAction?: (event: AgentToolAction) => void;
|
|
41
|
+
onGeneratingChange?: (generating: boolean) => void;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function activityStatus(messages: AgentMessage[]): string {
|
|
45
|
+
const last = messages[messages.length - 1];
|
|
46
|
+
if (!last) return "Thinking...";
|
|
47
|
+
if (last.role === "tool-call") {
|
|
48
|
+
if (last.toolOutput != null || last.toolError) return "Waiting for model...";
|
|
49
|
+
return `Running ${last.toolLabel ?? formatToolName(last.toolName ?? "tool")}...`;
|
|
50
|
+
}
|
|
51
|
+
if (last.role === "assistant" && last.streaming && last.content) return "Writing...";
|
|
52
|
+
return "Thinking...";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function useStickToBottom() {
|
|
56
|
+
const elRef = useRef<HTMLDivElement | null>(null);
|
|
57
|
+
const pinned = useRef(true);
|
|
58
|
+
|
|
59
|
+
const scrollRef = useCallback((node: HTMLDivElement | null) => {
|
|
60
|
+
elRef.current = node;
|
|
61
|
+
}, []);
|
|
62
|
+
|
|
63
|
+
const onScroll = () => {
|
|
64
|
+
const el = elRef.current;
|
|
65
|
+
if (!el) return;
|
|
66
|
+
pinned.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const scrollToBottom = useCallback((opts?: { force?: boolean }) => {
|
|
70
|
+
const el = elRef.current;
|
|
71
|
+
if (!el) return;
|
|
72
|
+
if (!opts?.force && !pinned.current) return;
|
|
73
|
+
pinned.current = true;
|
|
74
|
+
el.scrollTop = el.scrollHeight;
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
return { scrollRef, onScroll, scrollToBottom };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function MessageRow({ msg, generating }: { msg: AgentMessage; generating: boolean }) {
|
|
81
|
+
if (msg.role === "user") return <ChatUserMessage content={msg.content} />;
|
|
82
|
+
if (msg.role === "error") return <ChatError>{msg.content}</ChatError>;
|
|
83
|
+
if (msg.role === "thinking") {
|
|
84
|
+
return <ChatThinking thinking={msg.content} duration={(msg.meta?.thinkingDuration as number) ?? 0} />;
|
|
85
|
+
}
|
|
86
|
+
if (msg.role === "tool-call") {
|
|
87
|
+
const CustomUI = resolveToolUI(msg.toolName);
|
|
88
|
+
if (CustomUI) {
|
|
89
|
+
return <CustomUI msg={msg} generating={generating} showAvatar={false} />;
|
|
90
|
+
}
|
|
91
|
+
if (parseBgTaskRef(msg.toolOutput)) {
|
|
92
|
+
return <BackgroundTaskToolUI msg={msg} generating={generating} showAvatar={false} />;
|
|
93
|
+
}
|
|
94
|
+
const pending = msg.toolOutput == null && !msg.toolError;
|
|
95
|
+
return (
|
|
96
|
+
<ChatToolCall
|
|
97
|
+
toolName={msg.toolName}
|
|
98
|
+
label={msg.toolLabel}
|
|
99
|
+
toolInput={msg.toolInput}
|
|
100
|
+
toolOutput={msg.toolOutput}
|
|
101
|
+
toolError={msg.toolError}
|
|
102
|
+
running={pending && generating}
|
|
103
|
+
/>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const thinking = msg.meta?.thinking;
|
|
108
|
+
const thinkingDuration = msg.meta?.thinkingDuration;
|
|
109
|
+
return (
|
|
110
|
+
<ChatAgentMessage thinking={thinking} thinkingDuration={thinkingDuration ?? 0} thinkingStreaming={thinking != null && thinkingDuration == null}>
|
|
111
|
+
{msg.content ? <ChatMarkdown content={msg.content} streaming={!!msg.streaming} /> : null}
|
|
112
|
+
</ChatAgentMessage>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function AgentPanel({
|
|
117
|
+
endpoint,
|
|
118
|
+
title = "Assistant",
|
|
119
|
+
name,
|
|
120
|
+
description,
|
|
121
|
+
avatar,
|
|
122
|
+
starters,
|
|
123
|
+
placeholder = "Message…",
|
|
124
|
+
extraBody,
|
|
125
|
+
headers,
|
|
126
|
+
fetcher,
|
|
127
|
+
initialMessages,
|
|
128
|
+
toolbar,
|
|
129
|
+
accessory,
|
|
130
|
+
emptyState,
|
|
131
|
+
scrollbar = "hover",
|
|
132
|
+
className,
|
|
133
|
+
onToolAction,
|
|
134
|
+
onGeneratingChange,
|
|
135
|
+
}: AgentPanelProps) {
|
|
136
|
+
const { messages, generating, send, cancel, clear } = useAgentStream({
|
|
137
|
+
endpoint,
|
|
138
|
+
extraBody,
|
|
139
|
+
headers,
|
|
140
|
+
fetcher,
|
|
141
|
+
initialMessages,
|
|
142
|
+
onToolAction,
|
|
143
|
+
});
|
|
144
|
+
const { scrollRef, onScroll, scrollToBottom } = useStickToBottom();
|
|
145
|
+
const [epoch, setEpoch] = useState(0);
|
|
146
|
+
const welcomeName = name ?? title;
|
|
147
|
+
const lastMsg = messages[messages.length - 1];
|
|
148
|
+
const status = activityStatus(messages);
|
|
149
|
+
const lastToolPending = lastMsg?.role === "tool-call" && lastMsg.toolOutput == null && !lastMsg.toolError;
|
|
150
|
+
const hasActiveThinking = lastMsg?.role === "assistant" && Boolean(lastMsg.meta?.thinking) && lastMsg.meta?.thinkingDuration == null;
|
|
151
|
+
const isLiveText = status === "Writing..." || (lastMsg?.role === "assistant" && Boolean(lastMsg.streaming) && Boolean(lastMsg.content));
|
|
152
|
+
const showFooter = generating && !hasActiveThinking && !lastToolPending && !isLiveText;
|
|
153
|
+
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
onGeneratingChange?.(generating);
|
|
156
|
+
}, [generating, onGeneratingChange]);
|
|
157
|
+
|
|
158
|
+
useLayoutEffect(() => {
|
|
159
|
+
scrollToBottom();
|
|
160
|
+
}, [messages, generating, showFooter, scrollToBottom]);
|
|
161
|
+
|
|
162
|
+
const sendMessage = (text: string) => {
|
|
163
|
+
scrollToBottom({ force: true });
|
|
164
|
+
void send(text);
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const handleNewChat = () => {
|
|
168
|
+
clear();
|
|
169
|
+
setEpoch((n) => n + 1);
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
<div className={cn("nonla-agent-panel relative flex h-full min-h-0 flex-col overflow-hidden bg-chat", className)}>
|
|
174
|
+
<OverlayScroll visibility={scrollbar} scrollRef={scrollRef} onScroll={onScroll} className="flex-1 min-h-0 min-w-0" innerClassName={messages.length === 0 ? "flex flex-col" : undefined}>
|
|
175
|
+
<div data-chat-scroll-content className={`max-w-190 mx-auto min-w-0 w-full ${messages.length === 0 ? "flex flex-1 flex-col pb-24" : "pt-4 pb-28"}`}>
|
|
176
|
+
{messages.length === 0 ? (
|
|
177
|
+
(emptyState ?? (
|
|
178
|
+
<ChatWelcome name={welcomeName} description={description} avatar={avatar} starters={starters} onStarter={sendMessage} disabled={generating} />
|
|
179
|
+
))
|
|
180
|
+
) : (
|
|
181
|
+
<div className="flex flex-col">
|
|
182
|
+
{messages.map((msg) => (
|
|
183
|
+
<MessageRow key={msg.id} msg={msg} generating={generating} />
|
|
184
|
+
))}
|
|
185
|
+
{showFooter ? (
|
|
186
|
+
<div className="mt-1 px-4 pb-0.5">
|
|
187
|
+
<span className="nonla-chat-shimmer text-(length:--chat-body-size) leading-5.5 font-medium">{status}</span>
|
|
188
|
+
</div>
|
|
189
|
+
) : null}
|
|
190
|
+
</div>
|
|
191
|
+
)}
|
|
192
|
+
</div>
|
|
193
|
+
</OverlayScroll>
|
|
194
|
+
|
|
195
|
+
<div className="relative shrink-0">
|
|
196
|
+
{accessory}
|
|
197
|
+
<div className="mx-auto w-full max-w-190">
|
|
198
|
+
<ChatInput generating={generating} placeholder={placeholder} onSend={sendMessage} onCancel={cancel} onClear={handleNewChat} toolbar={toolbar} focusSignal={String(epoch)} enableTypeToFocus />
|
|
199
|
+
</div>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export type ChatBgTaskStatus = "running" | "completed" | "failed" | "cancelled" | "expired";
|
|
2
|
+
|
|
3
|
+
export type ChatBgTask = {
|
|
4
|
+
taskId: string;
|
|
5
|
+
toolId?: string;
|
|
6
|
+
toolName: string;
|
|
7
|
+
conversationId?: string | null;
|
|
8
|
+
status: ChatBgTaskStatus;
|
|
9
|
+
startedAt: number;
|
|
10
|
+
finishedAt?: number;
|
|
11
|
+
error?: string;
|
|
12
|
+
console?: string;
|
|
13
|
+
result?: unknown;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function parseBgTaskRef(output: unknown): { taskId: string; toolName?: string } | null {
|
|
17
|
+
if (output == null) return null;
|
|
18
|
+
let parsed: unknown = output;
|
|
19
|
+
if (typeof output === "string") {
|
|
20
|
+
try {
|
|
21
|
+
parsed = JSON.parse(output);
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (!parsed || typeof parsed !== "object") return null;
|
|
27
|
+
const rec = parsed as Record<string, unknown>;
|
|
28
|
+
if (rec.status !== "running" || typeof rec.taskId !== "string" || !rec.taskId) return null;
|
|
29
|
+
return { taskId: rec.taskId, toolName: typeof rec.toolName === "string" ? rec.toolName : undefined };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function formatBgElapsed(startedAt: number, now: number, finishedAt?: number): string {
|
|
33
|
+
const end = finishedAt && finishedAt > startedAt ? finishedAt : now;
|
|
34
|
+
const sec = Math.max(0, Math.floor((end - startedAt) / 1000));
|
|
35
|
+
if (sec < 60) return `${sec}s`;
|
|
36
|
+
const min = Math.floor(sec / 60);
|
|
37
|
+
const rem = sec % 60;
|
|
38
|
+
if (min < 60) return rem ? `${min}m ${rem}s` : `${min}m`;
|
|
39
|
+
const hr = Math.floor(min / 60);
|
|
40
|
+
return `${hr}h ${min % 60}m`;
|
|
41
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared SSE protocol for agent streams.
|
|
3
|
+
*
|
|
4
|
+
* Canonical event types:
|
|
5
|
+
* text-delta | thinking-delta | tool-call | tool-result | done | error
|
|
6
|
+
*
|
|
7
|
+
* Legacy aliases (`chunk`, `thinking`) are normalized.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type AgentSseEvent =
|
|
11
|
+
| { type: "text-delta"; text: string }
|
|
12
|
+
| { type: "thinking-delta"; text: string }
|
|
13
|
+
| { type: "tool-call"; toolCallId?: string; toolName: string; toolLabel?: string; input: unknown }
|
|
14
|
+
| { type: "tool-result"; toolCallId?: string; toolName: string; result: unknown }
|
|
15
|
+
| { type: "done"; text?: string; reason?: string }
|
|
16
|
+
| { type: "error"; error: string };
|
|
17
|
+
|
|
18
|
+
export interface AgentSseCallbacks {
|
|
19
|
+
onTextDelta: (text: string) => void;
|
|
20
|
+
onThinkingDelta: (text: string) => void;
|
|
21
|
+
onToolCall: (call: { toolCallId?: string; toolName: string; toolLabel?: string; input: unknown }) => void;
|
|
22
|
+
onToolResult: (call: { toolCallId?: string; toolName: string; result: unknown }) => void;
|
|
23
|
+
onDone: (text: string) => void | Promise<void>;
|
|
24
|
+
onError: (error: string) => void | Promise<void>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type ParseSseResult = "done" | "error" | "aborted" | "connection-lost";
|
|
28
|
+
|
|
29
|
+
export function normalizeSseEvent(raw: Record<string, unknown>): AgentSseEvent | null {
|
|
30
|
+
const type = raw.type;
|
|
31
|
+
if (type === "ping") return null;
|
|
32
|
+
if (type === "text-delta" || type === "chunk") {
|
|
33
|
+
return { type: "text-delta", text: String(raw.text ?? "") };
|
|
34
|
+
}
|
|
35
|
+
if (type === "thinking-delta" || type === "thinking") {
|
|
36
|
+
return { type: "thinking-delta", text: String(raw.text ?? "") };
|
|
37
|
+
}
|
|
38
|
+
if (type === "tool-call") {
|
|
39
|
+
return {
|
|
40
|
+
type: "tool-call",
|
|
41
|
+
toolCallId: raw.toolCallId as string | undefined,
|
|
42
|
+
toolName: String(raw.toolName ?? "unknown"),
|
|
43
|
+
toolLabel: raw.toolLabel as string | undefined,
|
|
44
|
+
input: raw.input,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
if (type === "tool-result") {
|
|
48
|
+
return {
|
|
49
|
+
type: "tool-result",
|
|
50
|
+
toolCallId: raw.toolCallId as string | undefined,
|
|
51
|
+
toolName: String(raw.toolName ?? "unknown"),
|
|
52
|
+
result: raw.result,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (type === "context-usage" || type === "token-usage") return null;
|
|
56
|
+
if (type === "done") {
|
|
57
|
+
return { type: "done", text: raw.text as string | undefined, reason: raw.reason as string | undefined };
|
|
58
|
+
}
|
|
59
|
+
if (type === "error") {
|
|
60
|
+
return { type: "error", error: String(raw.error ?? "Unknown error") };
|
|
61
|
+
}
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function parseSseStream(body: ReadableStream<Uint8Array>, callbacks: AgentSseCallbacks, options?: { signal?: AbortSignal }): Promise<ParseSseResult> {
|
|
66
|
+
const reader = body.getReader();
|
|
67
|
+
const decoder = new TextDecoder();
|
|
68
|
+
let buffer = "";
|
|
69
|
+
let gotTerminal = false;
|
|
70
|
+
let terminal: ParseSseResult = "connection-lost";
|
|
71
|
+
|
|
72
|
+
const handleLine = async (line: string): Promise<boolean> => {
|
|
73
|
+
const trimmed = line.trim();
|
|
74
|
+
if (!trimmed.startsWith("data:")) return false;
|
|
75
|
+
const jsonStr = trimmed.slice(5).trim();
|
|
76
|
+
if (!jsonStr) return false;
|
|
77
|
+
|
|
78
|
+
let raw: Record<string, unknown>;
|
|
79
|
+
try {
|
|
80
|
+
raw = JSON.parse(jsonStr) as Record<string, unknown>;
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const event = normalizeSseEvent(raw);
|
|
86
|
+
if (!event) return false;
|
|
87
|
+
|
|
88
|
+
switch (event.type) {
|
|
89
|
+
case "text-delta":
|
|
90
|
+
callbacks.onTextDelta(event.text);
|
|
91
|
+
return false;
|
|
92
|
+
case "thinking-delta":
|
|
93
|
+
callbacks.onThinkingDelta(event.text);
|
|
94
|
+
return false;
|
|
95
|
+
case "tool-call":
|
|
96
|
+
callbacks.onToolCall({
|
|
97
|
+
toolCallId: event.toolCallId,
|
|
98
|
+
toolName: event.toolName,
|
|
99
|
+
toolLabel: event.toolLabel,
|
|
100
|
+
input: event.input,
|
|
101
|
+
});
|
|
102
|
+
return false;
|
|
103
|
+
case "tool-result":
|
|
104
|
+
callbacks.onToolResult({
|
|
105
|
+
toolCallId: event.toolCallId,
|
|
106
|
+
toolName: event.toolName,
|
|
107
|
+
result: event.result,
|
|
108
|
+
});
|
|
109
|
+
return false;
|
|
110
|
+
case "done":
|
|
111
|
+
gotTerminal = true;
|
|
112
|
+
terminal = "done";
|
|
113
|
+
await Promise.resolve(callbacks.onDone(event.text ?? ""));
|
|
114
|
+
return true;
|
|
115
|
+
case "error":
|
|
116
|
+
gotTerminal = true;
|
|
117
|
+
terminal = "error";
|
|
118
|
+
await Promise.resolve(callbacks.onError(event.error));
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
while (true) {
|
|
125
|
+
if (options?.signal?.aborted) return "aborted";
|
|
126
|
+
|
|
127
|
+
const { done, value } = await reader.read();
|
|
128
|
+
if (done) {
|
|
129
|
+
buffer += decoder.decode();
|
|
130
|
+
for (const line of buffer.split("\n")) {
|
|
131
|
+
if (await handleLine(line)) return terminal;
|
|
132
|
+
}
|
|
133
|
+
break;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
buffer += decoder.decode(value, { stream: true });
|
|
137
|
+
const lines = buffer.split("\n");
|
|
138
|
+
buffer = lines.pop() ?? "";
|
|
139
|
+
|
|
140
|
+
for (const line of lines) {
|
|
141
|
+
if (await handleLine(line)) return terminal;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!gotTerminal && !options?.signal?.aborted) {
|
|
146
|
+
await Promise.resolve(callbacks.onError("Connection lost"));
|
|
147
|
+
return "connection-lost";
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return gotTerminal ? terminal : "aborted";
|
|
151
|
+
} catch (err) {
|
|
152
|
+
if ((err as Error).name === "AbortError" || options?.signal?.aborted) return "aborted";
|
|
153
|
+
await Promise.resolve(callbacks.onError((err as Error).message ?? "Stream read error"));
|
|
154
|
+
return "error";
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
export type AgentMessageRole = "user" | "assistant" | "thinking" | "tool-call" | "error";
|
|
2
|
+
|
|
3
|
+
export type AgentMessage = {
|
|
4
|
+
id: string;
|
|
5
|
+
role: AgentMessageRole;
|
|
6
|
+
content: string;
|
|
7
|
+
streaming?: boolean;
|
|
8
|
+
timestamp: Date;
|
|
9
|
+
toolCallId?: string;
|
|
10
|
+
toolName?: string;
|
|
11
|
+
toolLabel?: string;
|
|
12
|
+
toolInput?: unknown;
|
|
13
|
+
toolOutput?: unknown;
|
|
14
|
+
toolError?: boolean | string;
|
|
15
|
+
meta?: {
|
|
16
|
+
thinking?: string;
|
|
17
|
+
thinkingDuration?: number;
|
|
18
|
+
};
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type AgentHistoryMessage =
|
|
22
|
+
| { role: "user"; content: string }
|
|
23
|
+
| { role: "assistant"; content: string }
|
|
24
|
+
| {
|
|
25
|
+
role: "tool-call";
|
|
26
|
+
content: "";
|
|
27
|
+
toolCallId?: string;
|
|
28
|
+
toolName?: string;
|
|
29
|
+
toolInput?: unknown;
|
|
30
|
+
toolOutput?: unknown;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type AgentStreamRequest = {
|
|
34
|
+
messages: AgentHistoryMessage[];
|
|
35
|
+
signal: AbortSignal;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/** POST URL, or a function that returns an SSE `Response`. */
|
|
39
|
+
export type AgentPanelEndpoint = string | ((req: AgentStreamRequest) => Promise<Response>);
|
|
40
|
+
|
|
41
|
+
export type AgentToolAction =
|
|
42
|
+
| { type: "tool-call"; toolName: string; toolLabel: string; input: unknown }
|
|
43
|
+
| { type: "tool-result"; toolName: string; output: unknown };
|