devnonla-ui 0.1.0 → 0.2.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/README.md +22 -10
- package/package.json +2 -2
- package/src/alert/Alert.tsx +1 -1
- package/src/app/App.tsx +38 -18
- package/src/app/context.ts +32 -0
- package/src/button/Button.tsx +2 -2
- package/src/calendar/Calendar.tsx +4 -4
- package/src/chat/AgentPanel.tsx +15 -6
- package/src/chat/common/types.ts +27 -2
- package/src/chat/common/useAgentStream.ts +78 -12
- package/src/chat/common/utils.ts +13 -0
- package/src/chat/index.ts +5 -3
- package/src/chat/message-ui/ChatAgentMessage.tsx +1 -1
- package/src/chat/message-ui/ChatInput.tsx +1 -1
- package/src/chat/message-ui/ChatMarkdown.tsx +6 -6
- package/src/chat/message-ui/ChatMarkdownTable.tsx +1 -1
- package/src/chat/message-ui/ChatThinking.tsx +4 -3
- package/src/chat/message-ui/ChatUserMessage.tsx +0 -8
- package/src/chat/message-ui/MermaidBlock.tsx +11 -1
- package/src/chat/tool-ui/BackgroundTaskToolUI.tsx +2 -1
- package/src/chat/tool-ui/CallAgentToolUI.tsx +3 -2
- package/src/chat/tool-ui/ChatToolCall.tsx +3 -2
- package/src/chat/tool-ui/GetCurrentTimeToolUI.tsx +2 -1
- package/src/chat/tool-ui/ReadSkillToolUI.tsx +3 -2
- package/src/chat/tool-ui/RunJsToolUI.tsx +3 -2
- package/src/chat/tool-ui/WebFetchToolUI.tsx +3 -2
- package/src/chat/tool-ui/registry.ts +24 -12
- package/src/checkbox/Checkbox.tsx +5 -4
- package/src/codeblock/CodeBlock.tsx +1 -0
- package/src/datepicker/DatePicker.tsx +15 -9
- package/src/desktop/DesktopHeader.tsx +1 -1
- package/src/desktop/DesktopIcon.tsx +2 -4
- package/src/desktop/DesktopWindow.tsx +19 -5
- package/src/desktop/MeadowDesktop.tsx +2 -2
- package/src/drawer/Drawer.tsx +8 -6
- package/src/dropdown/ContextMenu.tsx +3 -1
- package/src/dropdown/Dropdown.tsx +8 -6
- package/src/form/Form.tsx +2 -2
- package/src/form/FormItem.tsx +2 -2
- package/src/form/partials/FormItemHelps.tsx +2 -2
- package/src/form/variants/FieldColor.tsx +1 -1
- package/src/form/variants/FieldObject.tsx +2 -2
- package/src/form/variants/FieldRepeaterItem.tsx +4 -4
- package/src/form-layout/FormLayout.tsx +6 -6
- package/src/index.ts +20 -6
- package/src/input/Input.tsx +11 -10
- package/src/lib/sizes.ts +13 -3
- package/src/lib/surface.ts +2 -1
- package/src/menu/Menu.tsx +2 -2
- package/src/message/message.tsx +35 -2
- package/src/modal/Modal.tsx +49 -3
- package/src/pagination/Pagination.tsx +6 -5
- package/src/popconfirm/Popconfirm.tsx +1 -1
- package/src/popover/Popover.tsx +6 -8
- package/src/scroll/OverlayScroll.tsx +5 -3
- package/src/segmented/Segmented.tsx +6 -5
- package/src/select/Select.tsx +9 -6
- package/src/shimmer/Shimmer.tsx +17 -0
- package/src/skeleton/Skeleton.tsx +3 -3
- package/src/spin/Spin.tsx +8 -5
- package/src/styles.css +81 -27
- package/src/switch/Switch.tsx +6 -5
- package/src/table/Table.tsx +6 -7
- package/src/tag/Tag.tsx +2 -2
- package/src/theme.ts +23 -3
- package/src/timepicker/TimePicker.tsx +13 -11
- package/src/tooltip/Tooltip.tsx +6 -7
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# devnonla-ui (NonlaUI)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
React controls for Nonla Agents.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
bun add devnonla-ui
|
|
@@ -10,7 +10,7 @@ Peer deps: `react` / `react-dom` >= 19, `react-hook-form`. Consumer should use T
|
|
|
10
10
|
|
|
11
11
|
```tsx
|
|
12
12
|
import "devnonla-ui/styles.css";
|
|
13
|
-
import { App, Button, EFormItemType,
|
|
13
|
+
import { App, Button, EFormItemType, SchemaForm, Modal, message, type TFormItemProps } from "devnonla-ui";
|
|
14
14
|
import { useForm } from "react-hook-form";
|
|
15
15
|
|
|
16
16
|
message.success("Saved");
|
|
@@ -31,7 +31,7 @@ function Example() {
|
|
|
31
31
|
return (
|
|
32
32
|
<App>
|
|
33
33
|
<form onSubmit={form.handleSubmit(console.log)}>
|
|
34
|
-
<
|
|
34
|
+
<SchemaForm form={form} items={items} />
|
|
35
35
|
<Button type="primary" htmlType="submit">
|
|
36
36
|
Save
|
|
37
37
|
</Button>
|
|
@@ -41,7 +41,7 @@ function Example() {
|
|
|
41
41
|
}
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
-
`
|
|
44
|
+
`SchemaForm` is JSON-schema driven (ZoForm-style) on `react-hook-form`. The BE can return `TFormItemProps[]`; FE hydrates `rules.pattern` strings to `RegExp`. Pass `fetcher` for `select_remote` fields. Layout-only labeled fields use `Form` / `Form.Item`.
|
|
45
45
|
|
|
46
46
|
# Sizes
|
|
47
47
|
|
|
@@ -67,7 +67,7 @@ All color lives in **`--nonla-*` knobs** (`src/styles.css`). Components never ha
|
|
|
67
67
|
}
|
|
68
68
|
```
|
|
69
69
|
|
|
70
|
-
`--brand-50` … `--brand-800` follow `--nonla-brand` (500 = the knob). Text on cream uses `brand-700`.
|
|
70
|
+
`--brand-50` … `--brand-800` follow `--nonla-brand` (500 = the knob). Text on cream uses `brand-700`. Text **on** the brand fill (Button primary, Tag solid, Checkbox) is `--nonla-solid-fg` / `colors.solidFg` — default white.
|
|
71
71
|
|
|
72
72
|
Or at runtime:
|
|
73
73
|
|
|
@@ -83,13 +83,21 @@ Or at runtime:
|
|
|
83
83
|
|
|
84
84
|
Flat knobs still work (`brand`, `colorBorder`, `colorBorderInput`, …). `applyNonlaTheme({ brand: "#3b82f6" })` does the same on `:root`.
|
|
85
85
|
|
|
86
|
-
Core knobs: `--nonla-bg`, `--nonla-fg`, `--nonla-brand`, `--nonla-danger`, `--nonla-success`, `--nonla-warn`, `--nonla-link`, `--nonla-radius` (small = −2px, large = +2px), `--nonla-height` / `--nonla-height-sm` / `--nonla-height-lg`, plus surfaces (`--nonla-surface`, `--nonla-chip`, …) and preset accents (`--nonla-blue`, `--nonla-purple`, …).
|
|
86
|
+
Core knobs: `--nonla-bg`, `--nonla-fg`, `--nonla-brand`, `--nonla-solid-fg`, `--nonla-danger`, `--nonla-success`, `--nonla-warn`, `--nonla-link`, `--nonla-radius` (small = −2px, large = +2px), `--nonla-height` / `--nonla-height-sm` / `--nonla-height-lg`, plus surfaces (`--nonla-surface`, `--nonla-chip`, …) and preset accents (`--nonla-blue`, `--nonla-purple`, …).
|
|
87
87
|
|
|
88
88
|
Those map into shadcn-standard tokens (`--background`, `--destructive`, …) plus Nonla aliases (`--brand`, `--success`, …).
|
|
89
89
|
|
|
90
90
|
- You: only touch `--nonla-*` (CSS or `theme` / `applyNonlaTheme`).
|
|
91
91
|
- Shadcn consumers: can still override `--background` / `--primary` / etc.
|
|
92
|
-
- Nonla CTA = `--brand` (from `--nonla-brand`). `--primary` = ink (emphasis), not the
|
|
92
|
+
- Nonla CTA = `--brand` (from `--nonla-brand`). Label on that fill = `--nonla-solid-fg`. `--primary` = ink (emphasis), not the brand button.
|
|
93
|
+
|
|
94
|
+
`<App>` is the ConfigProvider: `theme`, `componentSize`, `getPopupContainer`. Overlay portals (Select, Dropdown, DatePicker, Modal, Drawer, …) read `getPopupContainer`. `message.*` and `Modal.confirm` render into holders under `App`. `useToken()` / `getDesignToken()` read the live seed knobs.
|
|
95
|
+
|
|
96
|
+
```tsx
|
|
97
|
+
<App componentSize="large" getPopupContainer={() => document.getElementById("app")!}>
|
|
98
|
+
…
|
|
99
|
+
</App>
|
|
100
|
+
```
|
|
93
101
|
|
|
94
102
|
# Desktop
|
|
95
103
|
|
|
@@ -119,13 +127,17 @@ import { AgentPanel } from "devnonla-ui";
|
|
|
119
127
|
<AgentPanel
|
|
120
128
|
endpoint="/api/agents/abc/assistant/stream"
|
|
121
129
|
title="Nova"
|
|
122
|
-
|
|
130
|
+
toolbar={<ModelPicker />}
|
|
131
|
+
toolUis={[{ name: "get_calendar_events", component: CalendarToolUI }]}
|
|
132
|
+
toolHooks={[
|
|
133
|
+
{ name: "web_fetch", onCall: (e) => console.log("fetch", e.input), onResult: (e) => console.log("done", e.output) },
|
|
134
|
+
]}
|
|
123
135
|
/>
|
|
124
136
|
```
|
|
125
137
|
|
|
126
|
-
`endpoint` can also be a function that returns a `Response` (tests, mocks).
|
|
138
|
+
`endpoint` can also be a function that returns a `Response` (tests, mocks). Pass any node to `toolbar` for the composer (model picker, tools, …). Extra tool cards go in `toolUis` and win over builtins on the same `toolName`. `toolHooks` fire `onCall` / `onResult` for matching tools (omit `name` to hear every tool). `onToolAction` still catches all events.
|
|
127
139
|
|
|
128
|
-
POST body: `{ messages
|
|
140
|
+
POST body: `{ messages }` plus optional `extraBody`. Stream events:
|
|
129
141
|
|
|
130
142
|
`text-delta` | `thinking-delta` | `tool-call` | `tool-result` | `done` | `error`
|
|
131
143
|
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "devnonla-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"description": "NonlaUI —
|
|
5
|
+
"description": "NonlaUI — React controls for Nonla Agents",
|
|
6
6
|
"author": "devlangla <devlangla0x01@gmail.com>",
|
|
7
7
|
"license": "MIT",
|
|
8
8
|
"repository": {
|
package/src/alert/Alert.tsx
CHANGED
|
@@ -5,7 +5,7 @@ export type AlertType = "success" | "info" | "warning" | "error";
|
|
|
5
5
|
|
|
6
6
|
export type AlertProps = {
|
|
7
7
|
type?: AlertType;
|
|
8
|
-
/** Primary text —
|
|
8
|
+
/** Primary text — `message` / `title`. */
|
|
9
9
|
message?: ReactNode;
|
|
10
10
|
title?: ReactNode;
|
|
11
11
|
description?: ReactNode;
|
package/src/app/App.tsx
CHANGED
|
@@ -1,36 +1,56 @@
|
|
|
1
|
-
import { type ReactNode,
|
|
1
|
+
import { type ReactNode, useLayoutEffect, useMemo, useState } from "react";
|
|
2
2
|
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
|
3
|
+
import { message, MessageHolder } from "../message/message";
|
|
4
|
+
import { ConfirmHolder, Modal } from "../modal/Modal";
|
|
5
|
+
import { ControlSizeContext, normalizeSize, type ControlSize } from "../lib/sizes";
|
|
3
6
|
import { applyNonlaTheme, type NonlaThemeConfig } from "../theme";
|
|
7
|
+
import { AppContext, useAppConfig, usePopupContainer, useToken, type NonlaAppConfig } from "./context";
|
|
4
8
|
|
|
5
|
-
export type NonlaAppConfig
|
|
6
|
-
|
|
7
|
-
};
|
|
8
|
-
|
|
9
|
-
const AppContext = createContext<NonlaAppConfig>({});
|
|
10
|
-
|
|
11
|
-
export function useAppConfig() {
|
|
12
|
-
return useContext(AppContext);
|
|
13
|
-
}
|
|
9
|
+
export type { NonlaAppConfig };
|
|
10
|
+
export { useAppConfig, usePopupContainer, useToken };
|
|
14
11
|
|
|
15
12
|
export type AppProps = {
|
|
16
13
|
children: ReactNode;
|
|
17
14
|
getPopupContainer?: () => HTMLElement;
|
|
18
|
-
/**
|
|
15
|
+
/** Default control size (`componentSize`). Per-control `size` still wins. */
|
|
16
|
+
componentSize?: ControlSize;
|
|
17
|
+
/** Override `--nonla-*` knobs on `:root` (portals inherit). Prefer CSS in the host when possible. */
|
|
19
18
|
theme?: NonlaThemeConfig;
|
|
20
|
-
message?: unknown;
|
|
21
|
-
modal?: unknown;
|
|
22
19
|
};
|
|
23
20
|
|
|
24
|
-
/** Root host for NonlaUI
|
|
25
|
-
export function App({ children, getPopupContainer, theme }: AppProps) {
|
|
26
|
-
const
|
|
21
|
+
/** Root host for NonlaUI — ConfigProvider + message/modal holders. Mount once near app root. */
|
|
22
|
+
export function App({ children, getPopupContainer, componentSize, theme }: AppProps) {
|
|
23
|
+
const [themeRev, setThemeRev] = useState(0);
|
|
24
|
+
const size = componentSize ? normalizeSize(componentSize) : undefined;
|
|
25
|
+
const value = useMemo<NonlaAppConfig>(
|
|
26
|
+
() => ({ getPopupContainer, componentSize: size, themeRev }),
|
|
27
|
+
[getPopupContainer, size, themeRev],
|
|
28
|
+
);
|
|
29
|
+
|
|
27
30
|
useLayoutEffect(() => {
|
|
28
31
|
if (!theme) return;
|
|
29
|
-
|
|
32
|
+
const restore = applyNonlaTheme(theme);
|
|
33
|
+
setThemeRev((n) => n + 1);
|
|
34
|
+
return () => {
|
|
35
|
+
restore();
|
|
36
|
+
setThemeRev((n) => n + 1);
|
|
37
|
+
};
|
|
30
38
|
}, [theme]);
|
|
39
|
+
|
|
31
40
|
return (
|
|
32
41
|
<AppContext.Provider value={value}>
|
|
33
|
-
<
|
|
42
|
+
<ControlSizeContext.Provider value={size}>
|
|
43
|
+
<TooltipPrimitive.Provider delayDuration={100}>
|
|
44
|
+
{children}
|
|
45
|
+
<MessageHolder />
|
|
46
|
+
<ConfirmHolder />
|
|
47
|
+
</TooltipPrimitive.Provider>
|
|
48
|
+
</ControlSizeContext.Provider>
|
|
34
49
|
</AppContext.Provider>
|
|
35
50
|
);
|
|
36
51
|
}
|
|
52
|
+
|
|
53
|
+
/** Static APIs that follow this App tree (`useApp`). */
|
|
54
|
+
export function useApp() {
|
|
55
|
+
return { message, modal: Modal };
|
|
56
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { createContext, useContext, useMemo } from "react";
|
|
2
|
+
import type { CanonicalSize } from "../lib/sizes";
|
|
3
|
+
import { getDesignToken, type NonlaTokenSnapshot } from "../theme";
|
|
4
|
+
|
|
5
|
+
export type NonlaAppConfig = {
|
|
6
|
+
getPopupContainer?: () => HTMLElement;
|
|
7
|
+
componentSize?: CanonicalSize;
|
|
8
|
+
/** Bumps when `<App theme>` applies knobs — `useToken` re-reads CSS. */
|
|
9
|
+
themeRev: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const AppContext = createContext<NonlaAppConfig>({ themeRev: 0 });
|
|
13
|
+
|
|
14
|
+
export function useAppConfig() {
|
|
15
|
+
return useContext(AppContext);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Portal container for overlays (`getPopupContainer`). Always leaves the desktop stacking context. */
|
|
19
|
+
export function usePopupContainer(override?: () => HTMLElement) {
|
|
20
|
+
const { getPopupContainer } = useAppConfig();
|
|
21
|
+
return () => (override ?? getPopupContainer)?.() ?? document.body;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Computed seed knobs under the current App theme (`useToken`). */
|
|
25
|
+
export function useToken(): { token: NonlaTokenSnapshot } {
|
|
26
|
+
const { themeRev } = useAppConfig();
|
|
27
|
+
const token = useMemo(() => {
|
|
28
|
+
void themeRev;
|
|
29
|
+
return getDesignToken();
|
|
30
|
+
}, [themeRev]);
|
|
31
|
+
return { token };
|
|
32
|
+
}
|
package/src/button/Button.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type AnchorHTMLAttributes, type ButtonHTMLAttributes, type CSSProperties, type ForwardRefExoticComponent, type MouseEvent, type ReactNode, type RefAttributes, createContext, forwardRef, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import { cn } from "../lib/cn";
|
|
3
3
|
import type { ControlSize } from "../lib/sizes";
|
|
4
|
-
import { controlHeightVar, controlRadiusVar, getSizeTokens,
|
|
4
|
+
import { controlHeightVar, controlRadiusVar, getSizeTokens, useControlSize } from "../lib/sizes";
|
|
5
5
|
|
|
6
6
|
export type ButtonType = "default" | "primary" | "dashed" | "link" | "text";
|
|
7
7
|
export type ButtonVariant = "outlined" | "dashed" | "solid" | "filled" | "text" | "link";
|
|
@@ -154,7 +154,7 @@ const ButtonInner = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProp
|
|
|
154
154
|
const resolvedHtmlType = (typeIsHtml ? typeProp : htmlType) as ButtonHtmlType;
|
|
155
155
|
|
|
156
156
|
const groupSize = useContext(GroupSizeContext);
|
|
157
|
-
const size =
|
|
157
|
+
const size = useControlSize(sizeProp ?? groupSize);
|
|
158
158
|
const tok = getSizeTokens(size);
|
|
159
159
|
const [mergedColor, mergedVariant] = resolveTone(type, color, variant, danger);
|
|
160
160
|
const preset = PRESET_COLORS.has(mergedColor);
|
|
@@ -92,18 +92,18 @@ export function Calendar({
|
|
|
92
92
|
month: cn("flex w-full flex-col gap-3", defaultClassNames.month),
|
|
93
93
|
nav: cn("absolute inset-x-0 top-0 flex w-full items-center justify-between gap-1", defaultClassNames.nav),
|
|
94
94
|
button_previous: cn(
|
|
95
|
-
"inline-flex size-
|
|
95
|
+
"inline-flex size-(--cell-size) items-center justify-center rounded-md p-0 text-foreground select-none",
|
|
96
96
|
"hover:bg-brand-50 disabled:opacity-40",
|
|
97
97
|
defaultClassNames.button_previous,
|
|
98
98
|
),
|
|
99
99
|
button_next: cn(
|
|
100
|
-
"inline-flex size-
|
|
100
|
+
"inline-flex size-(--cell-size) items-center justify-center rounded-md p-0 text-foreground select-none",
|
|
101
101
|
"hover:bg-brand-50 disabled:opacity-40",
|
|
102
102
|
defaultClassNames.button_next,
|
|
103
103
|
),
|
|
104
|
-
month_caption: cn("flex h-
|
|
104
|
+
month_caption: cn("flex h-(--cell-size) w-full items-center justify-center px-(--cell-size)", defaultClassNames.month_caption),
|
|
105
105
|
caption_label: cn("select-none text-sm font-medium text-foreground", defaultClassNames.caption_label),
|
|
106
|
-
dropdowns: cn("flex h-
|
|
106
|
+
dropdowns: cn("flex h-(--cell-size) w-full items-center justify-center gap-1.5 text-sm font-medium", defaultClassNames.dropdowns),
|
|
107
107
|
month_grid: cn("w-full border-collapse", defaultClassNames.month_grid),
|
|
108
108
|
weekdays: cn("flex", defaultClassNames.weekdays),
|
|
109
109
|
weekday: cn("flex-1 select-none rounded-md text-[0.8rem] font-normal text-muted-foreground", defaultClassNames.weekday),
|
package/src/chat/AgentPanel.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
2
2
|
import { cn } from "../lib/cn";
|
|
3
3
|
import { OverlayScroll, type OverlayScrollVisibility } from "../scroll/OverlayScroll";
|
|
4
|
-
import type { AgentMessage, AgentPanelEndpoint, AgentToolAction } from "./common/types";
|
|
4
|
+
import type { AgentMessage, AgentPanelEndpoint, AgentToolAction, AgentToolHook } from "./common/types";
|
|
5
5
|
import { parseBgTaskRef } from "./common/bgTasks";
|
|
6
6
|
import { useAgentStream } from "./common/useAgentStream";
|
|
7
7
|
import { formatToolName } from "./common/utils";
|
|
@@ -14,7 +14,8 @@ import { ChatUserMessage } from "./message-ui/ChatUserMessage";
|
|
|
14
14
|
import { ChatWelcome } from "./message-ui/ChatWelcome";
|
|
15
15
|
import { BackgroundTaskToolUI } from "./tool-ui/BackgroundTaskToolUI";
|
|
16
16
|
import { ChatToolCall } from "./tool-ui/ChatToolCall";
|
|
17
|
-
import { resolveToolUI } from "./tool-ui/registry";
|
|
17
|
+
import { resolveToolUI, type AgentToolUI } from "./tool-ui/registry";
|
|
18
|
+
import { Shimmer } from "../shimmer/Shimmer";
|
|
18
19
|
|
|
19
20
|
export type AgentPanelProps = {
|
|
20
21
|
/** POST URL, or a function that returns an SSE `Response`. */
|
|
@@ -31,7 +32,12 @@ export type AgentPanelProps = {
|
|
|
31
32
|
headers?: Record<string, string>;
|
|
32
33
|
fetcher?: typeof fetch;
|
|
33
34
|
initialMessages?: AgentMessage[];
|
|
35
|
+
/** Custom node in the composer toolbar (model picker, tools, …). */
|
|
34
36
|
toolbar?: ReactNode;
|
|
37
|
+
/** Extra tool cards keyed by stream `toolName`. Checked before builtins. */
|
|
38
|
+
toolUis?: AgentToolUI[];
|
|
39
|
+
/** Tool lifecycle hooks, matched by `name` like `toolUis`. Omit `name` to hear every tool. */
|
|
40
|
+
toolHooks?: AgentToolHook[];
|
|
35
41
|
accessory?: ReactNode;
|
|
36
42
|
emptyState?: ReactNode;
|
|
37
43
|
/** Overlay scrollbar — does not reserve layout space. */
|
|
@@ -77,14 +83,14 @@ function useStickToBottom() {
|
|
|
77
83
|
return { scrollRef, onScroll, scrollToBottom };
|
|
78
84
|
}
|
|
79
85
|
|
|
80
|
-
function MessageRow({ msg, generating }: { msg: AgentMessage; generating: boolean }) {
|
|
86
|
+
function MessageRow({ msg, generating, toolUis }: { msg: AgentMessage; generating: boolean; toolUis?: AgentToolUI[] }) {
|
|
81
87
|
if (msg.role === "user") return <ChatUserMessage content={msg.content} />;
|
|
82
88
|
if (msg.role === "error") return <ChatError>{msg.content}</ChatError>;
|
|
83
89
|
if (msg.role === "thinking") {
|
|
84
90
|
return <ChatThinking thinking={msg.content} duration={(msg.meta?.thinkingDuration as number) ?? 0} />;
|
|
85
91
|
}
|
|
86
92
|
if (msg.role === "tool-call") {
|
|
87
|
-
const CustomUI = resolveToolUI(msg.toolName);
|
|
93
|
+
const CustomUI = resolveToolUI(msg.toolName, toolUis);
|
|
88
94
|
if (CustomUI) {
|
|
89
95
|
return <CustomUI msg={msg} generating={generating} showAvatar={false} />;
|
|
90
96
|
}
|
|
@@ -126,6 +132,8 @@ export function AgentPanel({
|
|
|
126
132
|
fetcher,
|
|
127
133
|
initialMessages,
|
|
128
134
|
toolbar,
|
|
135
|
+
toolUis,
|
|
136
|
+
toolHooks,
|
|
129
137
|
accessory,
|
|
130
138
|
emptyState,
|
|
131
139
|
scrollbar = "hover",
|
|
@@ -140,6 +148,7 @@ export function AgentPanel({
|
|
|
140
148
|
fetcher,
|
|
141
149
|
initialMessages,
|
|
142
150
|
onToolAction,
|
|
151
|
+
toolHooks,
|
|
143
152
|
});
|
|
144
153
|
const { scrollRef, onScroll, scrollToBottom } = useStickToBottom();
|
|
145
154
|
const [epoch, setEpoch] = useState(0);
|
|
@@ -180,11 +189,11 @@ export function AgentPanel({
|
|
|
180
189
|
) : (
|
|
181
190
|
<div className="flex flex-col">
|
|
182
191
|
{messages.map((msg) => (
|
|
183
|
-
<MessageRow key={msg.id} msg={msg} generating={generating} />
|
|
192
|
+
<MessageRow key={msg.id} msg={msg} generating={generating} toolUis={toolUis} />
|
|
184
193
|
))}
|
|
185
194
|
{showFooter ? (
|
|
186
195
|
<div className="mt-1 px-4 pb-0.5">
|
|
187
|
-
<
|
|
196
|
+
<Shimmer className="text-(length:--chat-body-size) leading-5.5 font-medium">{status}</Shimmer>
|
|
188
197
|
</div>
|
|
189
198
|
) : null}
|
|
190
199
|
</div>
|
package/src/chat/common/types.ts
CHANGED
|
@@ -38,6 +38,31 @@ export type AgentStreamRequest = {
|
|
|
38
38
|
/** POST URL, or a function that returns an SSE `Response`. */
|
|
39
39
|
export type AgentPanelEndpoint = string | ((req: AgentStreamRequest) => Promise<Response>);
|
|
40
40
|
|
|
41
|
+
/** Exact name, aliases, predicate, or omit/`*` for every tool. */
|
|
42
|
+
export type AgentToolNameMatch = string | readonly string[] | ((toolName: string) => boolean);
|
|
43
|
+
|
|
44
|
+
export type AgentToolCallEvent = {
|
|
45
|
+
toolCallId?: string;
|
|
46
|
+
toolName: string;
|
|
47
|
+
toolLabel: string;
|
|
48
|
+
input: unknown;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type AgentToolResultEvent = {
|
|
52
|
+
toolCallId?: string;
|
|
53
|
+
toolName: string;
|
|
54
|
+
output: unknown;
|
|
55
|
+
/** Set when the tool ended without a real result (cancel, disconnect, timeout). */
|
|
56
|
+
error?: string;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** Named tool lifecycle hooks. Omit `name` (or use `"*"`) to match every tool. */
|
|
60
|
+
export type AgentToolHook = {
|
|
61
|
+
name?: AgentToolNameMatch;
|
|
62
|
+
onCall?: (event: AgentToolCallEvent) => void;
|
|
63
|
+
onResult?: (event: AgentToolResultEvent) => void;
|
|
64
|
+
};
|
|
65
|
+
|
|
41
66
|
export type AgentToolAction =
|
|
42
|
-
| { type: "tool-call"
|
|
43
|
-
| { type: "tool-result"
|
|
67
|
+
| ({ type: "tool-call" } & AgentToolCallEvent)
|
|
68
|
+
| ({ type: "tool-result" } & AgentToolResultEvent);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
import { parseSseStream } from "./sse";
|
|
3
|
-
import type { AgentHistoryMessage, AgentMessage, AgentPanelEndpoint, AgentToolAction } from "./types";
|
|
4
|
-
import { formatToolName } from "./utils";
|
|
3
|
+
import type { AgentHistoryMessage, AgentMessage, AgentPanelEndpoint, AgentToolAction, AgentToolCallEvent, AgentToolHook, AgentToolResultEvent } from "./types";
|
|
4
|
+
import { formatToolName, matchesToolHook } from "./utils";
|
|
5
5
|
|
|
6
6
|
let _id = 0;
|
|
7
7
|
function nextId(prefix: string) {
|
|
@@ -69,9 +69,12 @@ export type UseAgentStreamOptions = {
|
|
|
69
69
|
fetcher?: typeof fetch;
|
|
70
70
|
initialMessages?: AgentMessage[];
|
|
71
71
|
onToolAction?: (event: AgentToolAction) => void;
|
|
72
|
+
toolHooks?: AgentToolHook[];
|
|
72
73
|
};
|
|
73
74
|
|
|
74
|
-
|
|
75
|
+
type OpenTool = AgentToolCallEvent;
|
|
76
|
+
|
|
77
|
+
export function useAgentStream({ endpoint, extraBody, headers, fetcher = fetch, initialMessages, onToolAction, toolHooks }: UseAgentStreamOptions) {
|
|
75
78
|
const [messages, setMessages] = useState<AgentMessage[]>(() => initialMessages ?? []);
|
|
76
79
|
const [generating, setGenerating] = useState(false);
|
|
77
80
|
const thinkingRef = useRef("");
|
|
@@ -79,10 +82,44 @@ export function useAgentStream({ endpoint, extraBody, headers, fetcher = fetch,
|
|
|
79
82
|
const abortRef = useRef<AbortController | null>(null);
|
|
80
83
|
const onToolActionRef = useRef(onToolAction);
|
|
81
84
|
onToolActionRef.current = onToolAction;
|
|
85
|
+
const toolHooksRef = useRef(toolHooks);
|
|
86
|
+
toolHooksRef.current = toolHooks;
|
|
82
87
|
const extraBodyRef = useRef(extraBody);
|
|
83
88
|
extraBodyRef.current = extraBody;
|
|
84
89
|
const endpointRef = useRef(endpoint);
|
|
85
90
|
endpointRef.current = endpoint;
|
|
91
|
+
const openToolsRef = useRef<Map<string, OpenTool>>(new Map());
|
|
92
|
+
|
|
93
|
+
const emitCall = useCallback((event: AgentToolCallEvent) => {
|
|
94
|
+
for (const hook of toolHooksRef.current ?? []) {
|
|
95
|
+
if (matchesToolHook(hook.name, event.toolName)) hook.onCall?.(event);
|
|
96
|
+
}
|
|
97
|
+
onToolActionRef.current?.({ type: "tool-call", ...event });
|
|
98
|
+
}, []);
|
|
99
|
+
|
|
100
|
+
const emitResult = useCallback((event: AgentToolResultEvent) => {
|
|
101
|
+
for (const hook of toolHooksRef.current ?? []) {
|
|
102
|
+
if (matchesToolHook(hook.name, event.toolName)) hook.onResult?.(event);
|
|
103
|
+
}
|
|
104
|
+
onToolActionRef.current?.({ type: "tool-result", ...event });
|
|
105
|
+
}, []);
|
|
106
|
+
|
|
107
|
+
const flushOpenTools = useCallback(
|
|
108
|
+
(error: string) => {
|
|
109
|
+
const open = [...openToolsRef.current.values()];
|
|
110
|
+
if (!open.length) return;
|
|
111
|
+
openToolsRef.current.clear();
|
|
112
|
+
for (const tool of open) {
|
|
113
|
+
emitResult({
|
|
114
|
+
toolCallId: tool.toolCallId,
|
|
115
|
+
toolName: tool.toolName,
|
|
116
|
+
output: { success: false, error },
|
|
117
|
+
error,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
[emitResult],
|
|
122
|
+
);
|
|
86
123
|
|
|
87
124
|
useEffect(() => {
|
|
88
125
|
return () => {
|
|
@@ -94,20 +131,22 @@ export function useAgentStream({ endpoint, extraBody, headers, fetcher = fetch,
|
|
|
94
131
|
const cancel = useCallback(() => {
|
|
95
132
|
abortRef.current?.abort();
|
|
96
133
|
abortRef.current = null;
|
|
134
|
+
flushOpenTools("Cancelled");
|
|
97
135
|
setMessages((prev) => finalizeStreaming(failOpenTools(prev, "Cancelled")));
|
|
98
136
|
setGenerating(false);
|
|
99
137
|
thinkingRef.current = "";
|
|
100
138
|
thinkingStartRef.current = 0;
|
|
101
|
-
}, []);
|
|
139
|
+
}, [flushOpenTools]);
|
|
102
140
|
|
|
103
141
|
const clear = useCallback(() => {
|
|
104
142
|
abortRef.current?.abort();
|
|
105
143
|
abortRef.current = null;
|
|
144
|
+
flushOpenTools("Cancelled");
|
|
106
145
|
setMessages([]);
|
|
107
146
|
setGenerating(false);
|
|
108
147
|
thinkingRef.current = "";
|
|
109
148
|
thinkingStartRef.current = 0;
|
|
110
|
-
}, []);
|
|
149
|
+
}, [flushOpenTools]);
|
|
111
150
|
|
|
112
151
|
const send = useCallback(
|
|
113
152
|
async (text: string) => {
|
|
@@ -115,6 +154,7 @@ export function useAgentStream({ endpoint, extraBody, headers, fetcher = fetch,
|
|
|
115
154
|
if (!trimmed || generating) return;
|
|
116
155
|
|
|
117
156
|
abortRef.current?.abort();
|
|
157
|
+
flushOpenTools("Cancelled");
|
|
118
158
|
const controller = new AbortController();
|
|
119
159
|
abortRef.current = controller;
|
|
120
160
|
|
|
@@ -259,10 +299,18 @@ export function useAgentStream({ endpoint, extraBody, headers, fetcher = fetch,
|
|
|
259
299
|
onToolCall: (event) => {
|
|
260
300
|
const label = event.toolLabel ? (event.toolLabel.includes(" ") ? event.toolLabel : formatToolName(event.toolLabel)) : formatToolName(event.toolName);
|
|
261
301
|
freezeOpen();
|
|
302
|
+
const id = nextId("tc");
|
|
303
|
+
const call: AgentToolCallEvent = {
|
|
304
|
+
toolCallId: event.toolCallId,
|
|
305
|
+
toolName: event.toolName,
|
|
306
|
+
toolLabel: label,
|
|
307
|
+
input: event.input,
|
|
308
|
+
};
|
|
309
|
+
openToolsRef.current.set(event.toolCallId || id, call);
|
|
262
310
|
setMessages((prev) => [
|
|
263
311
|
...prev,
|
|
264
312
|
{
|
|
265
|
-
id
|
|
313
|
+
id,
|
|
266
314
|
role: "tool-call",
|
|
267
315
|
content: event.toolName,
|
|
268
316
|
toolCallId: event.toolCallId,
|
|
@@ -272,11 +320,21 @@ export function useAgentStream({ endpoint, extraBody, headers, fetcher = fetch,
|
|
|
272
320
|
timestamp: new Date(),
|
|
273
321
|
},
|
|
274
322
|
]);
|
|
275
|
-
|
|
323
|
+
emitCall(call);
|
|
276
324
|
},
|
|
277
325
|
onToolResult: (event) => {
|
|
278
326
|
const raw = event.result;
|
|
279
327
|
const resultStr = typeof raw === "string" ? raw : JSON.stringify(raw);
|
|
328
|
+
if (event.toolCallId) {
|
|
329
|
+
openToolsRef.current.delete(event.toolCallId);
|
|
330
|
+
} else {
|
|
331
|
+
for (const [key, tool] of [...openToolsRef.current.entries()].reverse()) {
|
|
332
|
+
if (tool.toolName === event.toolName) {
|
|
333
|
+
openToolsRef.current.delete(key);
|
|
334
|
+
break;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
280
338
|
setMessages((prev) => {
|
|
281
339
|
let matchIdx = -1;
|
|
282
340
|
if (event.toolCallId) {
|
|
@@ -289,15 +347,18 @@ export function useAgentStream({ endpoint, extraBody, headers, fetcher = fetch,
|
|
|
289
347
|
if (matchIdx === -1) return prev;
|
|
290
348
|
return prev.map((m, i) => (i === matchIdx ? { ...m, toolOutput: resultStr } : m));
|
|
291
349
|
});
|
|
292
|
-
|
|
350
|
+
emitResult({ toolCallId: event.toolCallId, toolName: event.toolName, output: raw });
|
|
293
351
|
},
|
|
294
352
|
onDone: () => {
|
|
353
|
+
flushOpenTools("Tool did not return a result");
|
|
295
354
|
setMessages((prev) => finalizeStreaming(failOpenTools(prev, "Tool did not return a result")));
|
|
296
355
|
setGenerating(false);
|
|
297
356
|
thinkingRef.current = "";
|
|
298
357
|
thinkingStartRef.current = 0;
|
|
299
358
|
},
|
|
300
359
|
onError: (error) => {
|
|
360
|
+
const reason = error === "cancelled" ? "Cancelled" : error;
|
|
361
|
+
flushOpenTools(reason);
|
|
301
362
|
if (error === "Connection lost") {
|
|
302
363
|
setMessages((prev) => finalizeStreaming(failOpenTools(prev, "Connection lost")));
|
|
303
364
|
setGenerating(false);
|
|
@@ -306,7 +367,7 @@ export function useAgentStream({ endpoint, extraBody, headers, fetcher = fetch,
|
|
|
306
367
|
return;
|
|
307
368
|
}
|
|
308
369
|
setMessages((prev) => [
|
|
309
|
-
...finalizeStreaming(failOpenTools(prev,
|
|
370
|
+
...finalizeStreaming(failOpenTools(prev, reason)),
|
|
310
371
|
...(error === "cancelled" ? [] : [{ id: nextId("err"), role: "error" as const, content: error, timestamp: new Date() }]),
|
|
311
372
|
]);
|
|
312
373
|
setGenerating(false);
|
|
@@ -318,6 +379,7 @@ export function useAgentStream({ endpoint, extraBody, headers, fetcher = fetch,
|
|
|
318
379
|
);
|
|
319
380
|
|
|
320
381
|
if (result === "aborted") {
|
|
382
|
+
flushOpenTools("Cancelled");
|
|
321
383
|
setMessages((prev) => finalizeStreaming(failOpenTools(prev, "Cancelled")));
|
|
322
384
|
setGenerating(false);
|
|
323
385
|
thinkingRef.current = "";
|
|
@@ -325,26 +387,30 @@ export function useAgentStream({ endpoint, extraBody, headers, fetcher = fetch,
|
|
|
325
387
|
return;
|
|
326
388
|
}
|
|
327
389
|
|
|
390
|
+
flushOpenTools("Tool did not return a result");
|
|
328
391
|
setMessages((prev) => finalizeStreaming(failOpenTools(prev, "Tool did not return a result")));
|
|
329
392
|
setGenerating(false);
|
|
330
393
|
} catch (err: unknown) {
|
|
331
394
|
if ((err as Error)?.name === "AbortError") {
|
|
395
|
+
flushOpenTools("Cancelled");
|
|
332
396
|
setMessages((prev) => finalizeStreaming(failOpenTools(prev, "Cancelled")));
|
|
333
397
|
setGenerating(false);
|
|
334
398
|
thinkingRef.current = "";
|
|
335
399
|
thinkingStartRef.current = 0;
|
|
336
400
|
return;
|
|
337
401
|
}
|
|
402
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
403
|
+
flushOpenTools(message);
|
|
338
404
|
setMessages((prev) => [
|
|
339
|
-
...finalizeStreaming(failOpenTools(prev,
|
|
340
|
-
{ id: nextId("err"), role: "error", content:
|
|
405
|
+
...finalizeStreaming(failOpenTools(prev, message)),
|
|
406
|
+
{ id: nextId("err"), role: "error", content: message, timestamp: new Date() },
|
|
341
407
|
]);
|
|
342
408
|
setGenerating(false);
|
|
343
409
|
} finally {
|
|
344
410
|
abortRef.current = null;
|
|
345
411
|
}
|
|
346
412
|
},
|
|
347
|
-
[generating, messages, fetcher, headers],
|
|
413
|
+
[generating, messages, fetcher, headers, emitCall, emitResult, flushOpenTools],
|
|
348
414
|
);
|
|
349
415
|
|
|
350
416
|
return { messages, generating, send, cancel, clear };
|
package/src/chat/common/utils.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { AgentToolNameMatch } from "./types";
|
|
2
|
+
|
|
1
3
|
/** snake_case / camelCase → Title Case (e.g. edit_code → Edit Code). */
|
|
2
4
|
export function formatToolName(name: string): string {
|
|
3
5
|
return name
|
|
@@ -71,3 +73,14 @@ export function timestampMs(value: unknown): number {
|
|
|
71
73
|
}
|
|
72
74
|
return Date.now();
|
|
73
75
|
}
|
|
76
|
+
|
|
77
|
+
export function matchesToolName(match: AgentToolNameMatch, toolName: string): boolean {
|
|
78
|
+
if (typeof match === "function") return match(toolName);
|
|
79
|
+
if (typeof match === "string") return match === toolName;
|
|
80
|
+
return match.includes(toolName);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function matchesToolHook(match: AgentToolNameMatch | undefined, toolName: string): boolean {
|
|
84
|
+
if (match == null || match === "*") return true;
|
|
85
|
+
return matchesToolName(match, toolName);
|
|
86
|
+
}
|
package/src/chat/index.ts
CHANGED
|
@@ -13,8 +13,10 @@ export type { ChatErrorProps } from "./message-ui/ChatError";
|
|
|
13
13
|
export { ChatToolCall } from "./tool-ui/ChatToolCall";
|
|
14
14
|
export type { ChatToolCallProps } from "./tool-ui/ChatToolCall";
|
|
15
15
|
|
|
16
|
-
export { resolveToolUI } from "./tool-ui/registry";
|
|
16
|
+
export { resolveToolUI, builtinToolUis, matchesToolUIName } from "./tool-ui/registry";
|
|
17
|
+
export type { AgentToolUI, AgentToolUIName } from "./tool-ui/registry";
|
|
17
18
|
export type { ToolUIProps, ChatToolMessage } from "./tool-ui/types";
|
|
19
|
+
export { isToolRunning } from "./tool-ui/types";
|
|
18
20
|
export { CallAgentToolUI } from "./tool-ui/CallAgentToolUI";
|
|
19
21
|
export { WebFetchToolUI } from "./tool-ui/WebFetchToolUI";
|
|
20
22
|
export { GetCurrentTimeToolUI } from "./tool-ui/GetCurrentTimeToolUI";
|
|
@@ -48,9 +50,9 @@ export type { UseAgentStreamOptions } from "./common/useAgentStream";
|
|
|
48
50
|
export { parseSseStream, normalizeSseEvent } from "./common/sse";
|
|
49
51
|
export type { AgentSseEvent, AgentSseCallbacks, ParseSseResult } from "./common/sse";
|
|
50
52
|
|
|
51
|
-
export type { AgentMessage, AgentMessageRole, AgentHistoryMessage, AgentStreamRequest, AgentPanelEndpoint, AgentToolAction } from "./common/types";
|
|
53
|
+
export type { AgentMessage, AgentMessageRole, AgentHistoryMessage, AgentStreamRequest, AgentPanelEndpoint, AgentToolAction, AgentToolHook, AgentToolCallEvent, AgentToolResultEvent, AgentToolNameMatch } from "./common/types";
|
|
52
54
|
|
|
53
|
-
export { formatToolName, prettyJson, isCallAgentToolName, parseCallAgentToolTargetId } from "./common/utils";
|
|
55
|
+
export { formatToolName, prettyJson, isCallAgentToolName, parseCallAgentToolTargetId, matchesToolName, matchesToolHook } from "./common/utils";
|
|
54
56
|
|
|
55
57
|
export { parseBgTaskRef, formatBgElapsed } from "./common/bgTasks";
|
|
56
58
|
export type { ChatBgTask, ChatBgTaskStatus } from "./common/bgTasks";
|
|
@@ -21,7 +21,7 @@ export function ChatAgentMessage({ content, children, thinking, thinkingDuration
|
|
|
21
21
|
{thinking ? <ChatThinking thinking={thinking} duration={thinkingDuration ?? 0} streaming={thinkingStreaming} /> : null}
|
|
22
22
|
{body ? (
|
|
23
23
|
<div className="min-w-0 px-4 pb-0.5">
|
|
24
|
-
<div className="nonla-chat-body text-(length:--chat-body-size) leading-(--chat-body-leading) text-
|
|
24
|
+
<div className="nonla-chat-body text-(length:--chat-body-size) leading-(--chat-body-leading) text-(--nonla-ink)">{body}</div>
|
|
25
25
|
</div>
|
|
26
26
|
) : null}
|
|
27
27
|
</div>
|
|
@@ -136,7 +136,7 @@ export function ChatInput({ generating = false, placeholder = "Message…", disa
|
|
|
136
136
|
<div className="flex-1" />
|
|
137
137
|
|
|
138
138
|
{generating ? (
|
|
139
|
-
<button type="button" onClick={onCancel} title="Stop" className="w-6 h-6 rounded-full bg-brand text-
|
|
139
|
+
<button type="button" onClick={onCancel} title="Stop" className="w-6 h-6 rounded-full bg-brand text-(--nonla-solid-fg) flex items-center justify-center shrink-0 cursor-pointer hover:bg-brand/90 active:scale-95 transition-all duration-100 border-0">
|
|
140
140
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden>
|
|
141
141
|
<rect x="1.5" y="1.5" width="9" height="9" rx="2" fill="currentColor" />
|
|
142
142
|
</svg>
|