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,162 @@
|
|
|
1
|
+
import { type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
import { FluentIcon } from "../../icon/FluentIcon";
|
|
3
|
+
import { cn } from "../../lib/cn";
|
|
4
|
+
|
|
5
|
+
export type ChatInputProps = {
|
|
6
|
+
generating?: boolean;
|
|
7
|
+
placeholder?: string;
|
|
8
|
+
disabled?: boolean;
|
|
9
|
+
onSend: (text: string) => void;
|
|
10
|
+
onCancel?: () => void;
|
|
11
|
+
/** Clear conversation / start a new chat. */
|
|
12
|
+
onClear?: () => void;
|
|
13
|
+
/** Left-side toolbar (model picker, tools, …). */
|
|
14
|
+
toolbar?: ReactNode;
|
|
15
|
+
defaultValue?: string;
|
|
16
|
+
className?: string;
|
|
17
|
+
autoFocus?: boolean;
|
|
18
|
+
/** When this changes (e.g. conversation id), clear + focus the input. */
|
|
19
|
+
focusSignal?: string | null;
|
|
20
|
+
/** Redirect bare keypresses into this input when focus is elsewhere. */
|
|
21
|
+
enableTypeToFocus?: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function resize(el: HTMLTextAreaElement | null, maxRows = 10) {
|
|
25
|
+
if (!el) return;
|
|
26
|
+
el.style.height = "auto";
|
|
27
|
+
const line = Number.parseFloat(getComputedStyle(el).lineHeight) || 22;
|
|
28
|
+
const max = line * maxRows + 8;
|
|
29
|
+
el.style.height = `${Math.min(el.scrollHeight, max)}px`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isEditableTarget(el: EventTarget | null): boolean {
|
|
33
|
+
if (!(el instanceof HTMLElement)) return false;
|
|
34
|
+
const tag = el.tagName;
|
|
35
|
+
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable) return true;
|
|
36
|
+
return Boolean(el.closest("input, textarea, select, [contenteditable], [role='dialog'], [role='alertdialog'], [role='listbox'], [role='menu'], .monaco-editor"));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function ChatInput({ generating = false, placeholder = "Message…", disabled = false, onSend, onCancel, onClear, toolbar, defaultValue = "", className, autoFocus = false, focusSignal, enableTypeToFocus = false }: ChatInputProps) {
|
|
40
|
+
const [text, setText] = useState(defaultValue);
|
|
41
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
42
|
+
const hasText = text.trim().length > 0;
|
|
43
|
+
const canSend = !generating && !disabled && hasText;
|
|
44
|
+
|
|
45
|
+
useLayoutEffect(() => {
|
|
46
|
+
resize(textareaRef.current);
|
|
47
|
+
}, [text]);
|
|
48
|
+
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
const onSignal = focusSignal !== undefined;
|
|
51
|
+
if (!autoFocus && !onSignal) return;
|
|
52
|
+
if (onSignal) setText("");
|
|
53
|
+
|
|
54
|
+
let tries = 0;
|
|
55
|
+
let timer = 0;
|
|
56
|
+
const tryFocus = () => {
|
|
57
|
+
const ta = textareaRef.current;
|
|
58
|
+
if (!ta || ta.disabled) {
|
|
59
|
+
if (tries++ < 20) timer = window.setTimeout(tryFocus, 40);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
ta.focus({ preventScroll: true });
|
|
63
|
+
};
|
|
64
|
+
timer = window.setTimeout(tryFocus, 0);
|
|
65
|
+
return () => clearTimeout(timer);
|
|
66
|
+
}, [autoFocus, focusSignal]);
|
|
67
|
+
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
if (!enableTypeToFocus) return;
|
|
70
|
+
|
|
71
|
+
const onKeyDown = (e: globalThis.KeyboardEvent) => {
|
|
72
|
+
if (e.defaultPrevented || e.isComposing) return;
|
|
73
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return;
|
|
74
|
+
if (e.key.length !== 1) return;
|
|
75
|
+
|
|
76
|
+
const ta = textareaRef.current;
|
|
77
|
+
if (!ta || ta.disabled || document.activeElement === ta) return;
|
|
78
|
+
if (isEditableTarget(e.target) || isEditableTarget(document.activeElement)) return;
|
|
79
|
+
|
|
80
|
+
e.preventDefault();
|
|
81
|
+
ta.focus();
|
|
82
|
+
const start = ta.selectionStart ?? ta.value.length;
|
|
83
|
+
const end = ta.selectionEnd ?? ta.value.length;
|
|
84
|
+
const next = ta.value.slice(0, start) + e.key + ta.value.slice(end);
|
|
85
|
+
setText(next);
|
|
86
|
+
requestAnimationFrame(() => {
|
|
87
|
+
const nextTa = textareaRef.current;
|
|
88
|
+
if (!nextTa) return;
|
|
89
|
+
nextTa.selectionStart = nextTa.selectionEnd = start + e.key.length;
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
window.addEventListener("keydown", onKeyDown);
|
|
94
|
+
return () => window.removeEventListener("keydown", onKeyDown);
|
|
95
|
+
}, [enableTypeToFocus]);
|
|
96
|
+
|
|
97
|
+
const handleSend = () => {
|
|
98
|
+
if (!canSend) return;
|
|
99
|
+
onSend(text.trim());
|
|
100
|
+
setText("");
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const handleKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
|
104
|
+
if (e.nativeEvent.isComposing) return;
|
|
105
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
106
|
+
e.preventDefault();
|
|
107
|
+
handleSend();
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<div className={cn("nonla-glass relative isolate shrink-0 mx-4 mb-2.5 overflow-hidden pt-1 rounded-xl flex flex-col", disabled && "opacity-70", className)}>
|
|
113
|
+
<textarea
|
|
114
|
+
ref={textareaRef}
|
|
115
|
+
data-chat-input
|
|
116
|
+
value={text}
|
|
117
|
+
onChange={(e) => setText(e.target.value)}
|
|
118
|
+
onKeyDown={handleKeyDown}
|
|
119
|
+
disabled={generating || disabled}
|
|
120
|
+
placeholder={placeholder}
|
|
121
|
+
rows={1}
|
|
122
|
+
className={cn(
|
|
123
|
+
"w-full resize-none appearance-none border-0 bg-transparent shadow-none outline-none focus:outline-none px-2.5 pt-1 pb-2.5",
|
|
124
|
+
"text-(length:--chat-composer-size) leading-(--chat-composer-leading) font-normal",
|
|
125
|
+
disabled ? "text-muted-foreground cursor-not-allowed placeholder:text-quaternary-foreground" : "text-foreground placeholder:text-muted-foreground/60",
|
|
126
|
+
)}
|
|
127
|
+
/>
|
|
128
|
+
|
|
129
|
+
<div className="flex items-center gap-1.5 pb-2 px-2">
|
|
130
|
+
{onClear ? (
|
|
131
|
+
<button type="button" onClick={onClear} title="New chat" aria-label="New chat" className="inline-flex size-6 shrink-0 items-center justify-center rounded-lg border-0 bg-transparent cursor-pointer hover:bg-ink-hover">
|
|
132
|
+
<FluentIcon name="chat-add-24" size={16} />
|
|
133
|
+
</button>
|
|
134
|
+
) : null}
|
|
135
|
+
{toolbar}
|
|
136
|
+
<div className="flex-1" />
|
|
137
|
+
|
|
138
|
+
{generating ? (
|
|
139
|
+
<button type="button" onClick={onCancel} title="Stop" className="w-6 h-6 rounded-full bg-brand text-white flex items-center justify-center shrink-0 cursor-pointer hover:bg-brand/90 active:scale-95 transition-all duration-100 border-0">
|
|
140
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden>
|
|
141
|
+
<rect x="1.5" y="1.5" width="9" height="9" rx="2" fill="currentColor" />
|
|
142
|
+
</svg>
|
|
143
|
+
<span className="sr-only">Stop</span>
|
|
144
|
+
</button>
|
|
145
|
+
) : (
|
|
146
|
+
<button
|
|
147
|
+
type="button"
|
|
148
|
+
disabled={!canSend}
|
|
149
|
+
onClick={handleSend}
|
|
150
|
+
title="Send (Enter)"
|
|
151
|
+
className={cn("w-6 h-6 rounded-full flex items-center justify-center shrink-0 transition-all duration-100 border-0", canSend ? "bg-foreground text-background cursor-pointer" : "bg-border text-muted-foreground cursor-not-allowed opacity-50")}
|
|
152
|
+
>
|
|
153
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden>
|
|
154
|
+
<path d="M6 9.5V2.5M6 2.5L3 5.5M6 2.5L9 5.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
|
|
155
|
+
</svg>
|
|
156
|
+
<span className="sr-only">Send</span>
|
|
157
|
+
</button>
|
|
158
|
+
)}
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { useRef } from "react";
|
|
2
|
+
import ReactMarkdown, { type Components } from "react-markdown";
|
|
3
|
+
import remarkGfm from "remark-gfm";
|
|
4
|
+
import { Checkbox } from "../../checkbox/Checkbox";
|
|
5
|
+
import { CodeBlock } from "../../codeblock/CodeBlock";
|
|
6
|
+
import { cn } from "../../lib/cn";
|
|
7
|
+
import { ChatMarkdownTable } from "./ChatMarkdownTable";
|
|
8
|
+
import { MermaidBlock } from "./MermaidBlock";
|
|
9
|
+
import { isPendingMermaidBlock } from "./mermaidFence";
|
|
10
|
+
|
|
11
|
+
/** Message body — sizes come from --chat-* tokens. */
|
|
12
|
+
export const chatBodyClass = "font-normal text-foreground text-(length:--chat-body-size) leading-(--chat-body-leading)";
|
|
13
|
+
|
|
14
|
+
/** Shared Tailwind prose classes for chat markdown. */
|
|
15
|
+
export const chatMarkdownClass = cn(
|
|
16
|
+
chatBodyClass,
|
|
17
|
+
"min-w-0 wrap-anywhere",
|
|
18
|
+
"[&_p]:m-0 [&_p]:mb-(--chat-p-mb) [&_p:last-child]:mb-0",
|
|
19
|
+
"[&_h1]:mt-(--chat-h-mt) [&_h1]:mb-(--chat-h-mb) [&_h1]:text-(length:--chat-h1-size) [&_h1]:font-semibold [&_h1]:leading-snug",
|
|
20
|
+
"[&_h2]:mt-(--chat-h-mt) [&_h2]:mb-(--chat-h-mb) [&_h2]:text-(length:--chat-h2-size) [&_h2]:font-semibold [&_h2]:leading-snug",
|
|
21
|
+
"[&_h3]:mt-(--chat-h-mt) [&_h3]:mb-(--chat-h-mb) [&_h3]:text-(length:--chat-h3-size) [&_h3]:font-semibold [&_h3]:leading-snug",
|
|
22
|
+
"[&_h4]:mt-(--chat-h-mt) [&_h4]:mb-(--chat-h-mb) [&_h4]:text-(length:--chat-h4-size) [&_h4]:font-semibold [&_h4]:leading-snug",
|
|
23
|
+
"[&_h1:first-child]:mt-0 [&_h2:first-child]:mt-0 [&_h3:first-child]:mt-0 [&_h4:first-child]:mt-0",
|
|
24
|
+
"[&_strong]:font-semibold [&_em]:italic",
|
|
25
|
+
"[&_blockquote]:m-0 [&_blockquote]:mb-(--chat-p-mb) [&_blockquote]:border-0 [&_blockquote]:p-0 [&_blockquote]:not-italic [&_blockquote]:text-inherit [&_blockquote:last-child]:mb-0",
|
|
26
|
+
"[&_ul]:mt-2 [&_ul]:mb-(--chat-p-mb)",
|
|
27
|
+
"[&_ul:not(.contains-task-list)]:list-disc [&_ul:not(.contains-task-list)]:pl-[26px]",
|
|
28
|
+
"[&_ul.contains-task-list]:list-none [&_ul.contains-task-list]:pl-[26px]",
|
|
29
|
+
"[&_li.task-list-item]:relative",
|
|
30
|
+
"[&_li.task-list-item>.nonla-md-task-check]:absolute [&_li.task-list-item>.nonla-md-task-check]:top-0 [&_li.task-list-item>.nonla-md-task-check]:left-[-22px]",
|
|
31
|
+
"[&_ol]:mt-2 [&_ol]:mb-(--chat-p-mb) [&_ol]:list-decimal [&_ol]:pl-[26px]",
|
|
32
|
+
"[&_li]:my-1.5 [&_li]:leading-(--chat-body-leading)",
|
|
33
|
+
"[&_a]:text-link [&_a]:no-underline [&_a]:hover:underline [&_a]:hover:underline-offset-[3px]",
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
export type ChatMarkdownStreamState = {
|
|
37
|
+
content: string;
|
|
38
|
+
streaming: boolean;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type ChatMarkdownProps = {
|
|
42
|
+
content: string;
|
|
43
|
+
streaming?: boolean;
|
|
44
|
+
className?: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Build markdown components. Pass getState so pending-mermaid can update via ref
|
|
49
|
+
* without recreating the components object (avoids remounting finished charts).
|
|
50
|
+
*/
|
|
51
|
+
export function createChatMarkdownComponents(getState?: () => ChatMarkdownStreamState): Components {
|
|
52
|
+
return {
|
|
53
|
+
code({ className, children }) {
|
|
54
|
+
const match = /language-(\w+)/.exec(className || "");
|
|
55
|
+
const lang = match?.[1] ?? "";
|
|
56
|
+
const codeText = String(children).replace(/\n$/, "");
|
|
57
|
+
const isBlock = codeText.includes("\n") || !!match;
|
|
58
|
+
|
|
59
|
+
if (isBlock) {
|
|
60
|
+
const isMermaid = lang.toLowerCase() === "mermaid";
|
|
61
|
+
if (isMermaid) {
|
|
62
|
+
const state = getState?.();
|
|
63
|
+
const pending = state ? isPendingMermaidBlock(state.content, codeText, state.streaming) : false;
|
|
64
|
+
if (!pending) return <MermaidBlock>{codeText}</MermaidBlock>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return <CodeBlock code={codeText} language={lang || undefined} className="my-3 last:mb-0" />;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return <span className="rounded-sm bg-gray-50/60 px-1">{children}</span>;
|
|
71
|
+
},
|
|
72
|
+
hr() {
|
|
73
|
+
return null;
|
|
74
|
+
},
|
|
75
|
+
table({ children }) {
|
|
76
|
+
return <ChatMarkdownTable>{children}</ChatMarkdownTable>;
|
|
77
|
+
},
|
|
78
|
+
th({ children }) {
|
|
79
|
+
return (
|
|
80
|
+
<th>
|
|
81
|
+
<div className="inline-block max-w-[300px] wrap-break-word">{children}</div>
|
|
82
|
+
</th>
|
|
83
|
+
);
|
|
84
|
+
},
|
|
85
|
+
td({ children }) {
|
|
86
|
+
return (
|
|
87
|
+
<td>
|
|
88
|
+
<div className="inline-block max-w-[300px] wrap-break-word">{children}</div>
|
|
89
|
+
</td>
|
|
90
|
+
);
|
|
91
|
+
},
|
|
92
|
+
a({ href, children, ...props }) {
|
|
93
|
+
return (
|
|
94
|
+
<a href={href} target="_blank" rel="noopener noreferrer" {...props}>
|
|
95
|
+
{children}
|
|
96
|
+
</a>
|
|
97
|
+
);
|
|
98
|
+
},
|
|
99
|
+
input({ type, checked }) {
|
|
100
|
+
if (type !== "checkbox") return <input type={type} checked={checked} readOnly />;
|
|
101
|
+
return (
|
|
102
|
+
<span className="nonla-md-task-check pointer-events-none inline-flex h-(--chat-body-leading) items-center">
|
|
103
|
+
<Checkbox checked={Boolean(checked)} tabIndex={-1} />
|
|
104
|
+
</span>
|
|
105
|
+
);
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const chatMarkdownComponents: Components = createChatMarkdownComponents();
|
|
111
|
+
|
|
112
|
+
export function ChatMarkdown({ content, streaming = false, className }: ChatMarkdownProps) {
|
|
113
|
+
const streamStateRef = useRef<ChatMarkdownStreamState>({ content, streaming });
|
|
114
|
+
streamStateRef.current = { content, streaming };
|
|
115
|
+
|
|
116
|
+
const componentsRef = useRef<Components | null>(null);
|
|
117
|
+
if (!componentsRef.current) {
|
|
118
|
+
componentsRef.current = createChatMarkdownComponents(() => streamStateRef.current);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<div className={cn(chatMarkdownClass, className)}>
|
|
123
|
+
<ReactMarkdown remarkPlugins={[remarkGfm]} components={componentsRef.current}>
|
|
124
|
+
{content}
|
|
125
|
+
</ReactMarkdown>
|
|
126
|
+
</div>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { type ReactNode, useCallback, useRef } from "react";
|
|
2
|
+
import { ButtonCopy } from "../../button/ButtonCopy";
|
|
3
|
+
|
|
4
|
+
function escapeCell(text: string): string {
|
|
5
|
+
return text.replace(/\|/g, "\\|").replace(/\n+/g, "").trim();
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function tableToMarkdown(table: HTMLTableElement): string {
|
|
9
|
+
const rows = Array.from(table.querySelectorAll("tr"));
|
|
10
|
+
const data = rows.map((tr) => Array.from(tr.querySelectorAll("th, td")).map((cell) => escapeCell(cell.textContent ?? "")));
|
|
11
|
+
if (data.length === 0) return "";
|
|
12
|
+
|
|
13
|
+
const colCount = Math.max(...data.map((row) => row.length));
|
|
14
|
+
const pad = (row: string[]) => Array.from({ length: colCount }, (_, i) => row[i] ?? "");
|
|
15
|
+
|
|
16
|
+
const header = pad(data[0]!);
|
|
17
|
+
const separator = header.map(() => "---");
|
|
18
|
+
const body = data.slice(1).map(pad);
|
|
19
|
+
|
|
20
|
+
return [`| ${header.join(" | ")} |`, `| ${separator.join(" | ")} |`, ...body.map((row) => `| ${row.join(" | ")} |`)].join("\n");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function ChatMarkdownTable({ children }: { children: ReactNode }) {
|
|
24
|
+
const tableRef = useRef<HTMLTableElement>(null);
|
|
25
|
+
|
|
26
|
+
const getText = useCallback(() => {
|
|
27
|
+
const table = tableRef.current;
|
|
28
|
+
return table ? tableToMarkdown(table) : "";
|
|
29
|
+
}, []);
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div className="relative my-4 max-w-full overflow-hidden rounded-xl border border-border bg-card group">
|
|
33
|
+
<ButtonCopy getText={getText} label="Copy as Markdown" className="absolute top-1 right-1 z-10 opacity-0 group-hover:opacity-100" />
|
|
34
|
+
|
|
35
|
+
<div className="overflow-x-auto">
|
|
36
|
+
<table
|
|
37
|
+
ref={tableRef}
|
|
38
|
+
className="w-max min-w-full wrap-normal border-separate border-spacing-0 text-[14px] [&_th]:bg-foreground/[0.04] [&_th]:text-foreground [&_th]:font-semibold [&_th]:px-2.5 [&_th]:py-1.5 [&_th]:text-left [&_th]:align-top [&_th]:border-r [&_th]:border-b [&_th]:border-border [&_th:last-child]:border-r-0 [&_td]:px-2.5 [&_td]:py-1.5 [&_td]:align-top [&_td]:text-foreground [&_td]:bg-card [&_td]:border-r [&_td]:border-b [&_td]:border-border [&_td:last-child]:border-r-0 [&_tbody_tr:last-child_td]:border-b-0"
|
|
39
|
+
>
|
|
40
|
+
{children}
|
|
41
|
+
</table>
|
|
42
|
+
</div>
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { cn } from "../../lib/cn";
|
|
2
|
+
|
|
3
|
+
export function ChatSpinner({ className }: { className?: string }) {
|
|
4
|
+
return <span className={cn("size-3 shrink-0 rounded-full border-2 border-muted-foreground/25 border-t-muted-foreground animate-spin", className)} aria-hidden />;
|
|
5
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
2
|
+
import { cn } from "../../lib/cn";
|
|
3
|
+
|
|
4
|
+
export type ChatThinkingProps = {
|
|
5
|
+
thinking: string;
|
|
6
|
+
/** Final duration in seconds (shown when not streaming). */
|
|
7
|
+
duration?: number;
|
|
8
|
+
streaming?: boolean;
|
|
9
|
+
className?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/** Collapsed by default; expand to read (and stream) reasoning. */
|
|
13
|
+
export function ChatThinking({ thinking, duration = 0, streaming = false, className }: ChatThinkingProps) {
|
|
14
|
+
const bodyRef = useRef<HTMLDivElement>(null);
|
|
15
|
+
const [elapsedSec, setElapsedSec] = useState(0);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (!streaming) return;
|
|
19
|
+
setElapsedSec(0);
|
|
20
|
+
const started = Date.now();
|
|
21
|
+
const id = setInterval(() => {
|
|
22
|
+
setElapsedSec(Math.floor((Date.now() - started) / 1000));
|
|
23
|
+
}, 1000);
|
|
24
|
+
return () => clearInterval(id);
|
|
25
|
+
}, [streaming]);
|
|
26
|
+
|
|
27
|
+
useLayoutEffect(() => {
|
|
28
|
+
if (!streaming) return;
|
|
29
|
+
const el = bodyRef.current;
|
|
30
|
+
if (el) el.scrollTop = el.scrollHeight;
|
|
31
|
+
}, [thinking, streaming]);
|
|
32
|
+
|
|
33
|
+
if (!thinking.trim()) return null;
|
|
34
|
+
|
|
35
|
+
const liveElapsed = Math.max(elapsedSec, duration);
|
|
36
|
+
const shownDuration = Math.max(1, duration);
|
|
37
|
+
const label = streaming ? (liveElapsed >= 3 ? `Thinking ${liveElapsed}s` : "Thinking") : `Thought ${shownDuration}s`;
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<details
|
|
41
|
+
className={cn("nonla-chat-thinking px-4 pb-2 group/thinking", className)}
|
|
42
|
+
style={{ overflowAnchor: "none" }}
|
|
43
|
+
onToggle={(e) => {
|
|
44
|
+
if (!streaming || !e.currentTarget.open) return;
|
|
45
|
+
const el = bodyRef.current;
|
|
46
|
+
if (el) el.scrollTop = el.scrollHeight;
|
|
47
|
+
}}
|
|
48
|
+
>
|
|
49
|
+
<summary className="cursor-pointer select-none text-sm font-medium text-tertiary-foreground flex items-center gap-1 py-0.5 list-none [&::-webkit-details-marker]:hidden">
|
|
50
|
+
<span className={streaming ? "nonla-chat-shimmer" : undefined}>{label}</span>
|
|
51
|
+
<svg className="w-3 h-3 shrink-0 opacity-0 transition-[opacity,transform] duration-150 group-hover/thinking:opacity-100 group-open/thinking:opacity-100 group-open/thinking:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden>
|
|
52
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
|
53
|
+
</svg>
|
|
54
|
+
</summary>
|
|
55
|
+
|
|
56
|
+
<div ref={bodyRef} className="pt-2 max-h-40 min-w-0 overflow-y-auto overflow-x-hidden mb-2 text-[14px] leading-normal">
|
|
57
|
+
<p className="text-[14px] leading-normal text-quaternary-foreground whitespace-pre-wrap wrap-break-word m-0">{thinking}</p>
|
|
58
|
+
</div>
|
|
59
|
+
</details>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { cn } from "../../lib/cn";
|
|
3
|
+
|
|
4
|
+
const MAX_HEIGHT = 150;
|
|
5
|
+
|
|
6
|
+
export type ChatUserMessageProps = {
|
|
7
|
+
content: string;
|
|
8
|
+
className?: string;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function ChatUserMessage({ content, className }: ChatUserMessageProps) {
|
|
12
|
+
const contentRef = useRef<HTMLDivElement>(null);
|
|
13
|
+
const [isOverflow, setIsOverflow] = useState(false);
|
|
14
|
+
const [isExpanded, setIsExpanded] = useState(false);
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const el = contentRef.current;
|
|
18
|
+
if (!el) return;
|
|
19
|
+
const measure = () => setIsOverflow(el.scrollHeight > MAX_HEIGHT);
|
|
20
|
+
measure();
|
|
21
|
+
const ro = new ResizeObserver(measure);
|
|
22
|
+
ro.observe(el);
|
|
23
|
+
return () => ro.disconnect();
|
|
24
|
+
}, [content]);
|
|
25
|
+
|
|
26
|
+
const collapsed = isOverflow && !isExpanded;
|
|
27
|
+
|
|
28
|
+
const toggle = () => {
|
|
29
|
+
const sel = window.getSelection();
|
|
30
|
+
if (sel?.toString()) return;
|
|
31
|
+
setIsExpanded((v) => !v);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<div
|
|
36
|
+
className={cn(
|
|
37
|
+
"nonla-chat-user relative isolate mt-6 mb-3 mx-4 overflow-hidden rounded-xl px-3 py-2.5",
|
|
38
|
+
"border border-brand/40 bg-[color-mix(in_oklab,var(--brand)_14%,var(--glass))] backdrop-blur-xl backdrop-saturate-150",
|
|
39
|
+
isOverflow && "cursor-pointer",
|
|
40
|
+
className,
|
|
41
|
+
)}
|
|
42
|
+
onClick={isOverflow ? toggle : undefined}
|
|
43
|
+
>
|
|
44
|
+
<div
|
|
45
|
+
ref={contentRef}
|
|
46
|
+
className={cn(
|
|
47
|
+
"overflow-hidden text-(length:--chat-body-size) leading-(--chat-body-leading) text-(--nonla-ink) whitespace-pre-wrap wrap-break-word",
|
|
48
|
+
collapsed && "mask-[linear-gradient(to_bottom,#000_calc(100%-1.75rem),transparent)]",
|
|
49
|
+
)}
|
|
50
|
+
style={{ maxHeight: collapsed ? MAX_HEIGHT : undefined }}
|
|
51
|
+
>
|
|
52
|
+
{content}
|
|
53
|
+
</div>
|
|
54
|
+
{isOverflow ? (
|
|
55
|
+
<button
|
|
56
|
+
type="button"
|
|
57
|
+
aria-expanded={isExpanded}
|
|
58
|
+
aria-label={isExpanded ? "Collapse message" : "Expand message"}
|
|
59
|
+
onClick={(e) => {
|
|
60
|
+
e.stopPropagation();
|
|
61
|
+
setIsExpanded((v) => !v);
|
|
62
|
+
}}
|
|
63
|
+
className="flex w-full cursor-pointer items-center justify-center border-0 bg-transparent pt-1 text-muted-foreground hover:text-foreground"
|
|
64
|
+
>
|
|
65
|
+
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden className={cn("transition-transform duration-200", isExpanded && "rotate-180")}>
|
|
66
|
+
<path d="M2.5 4.5 6 8l3.5-3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
67
|
+
</svg>
|
|
68
|
+
</button>
|
|
69
|
+
) : null}
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { cn } from "../../lib/cn";
|
|
3
|
+
|
|
4
|
+
export type ChatWelcomeProps = {
|
|
5
|
+
name: string;
|
|
6
|
+
description?: string | null;
|
|
7
|
+
/** Avatar node (image / initials). */
|
|
8
|
+
avatar?: ReactNode;
|
|
9
|
+
starters?: string[];
|
|
10
|
+
onStarter?: (text: string) => void;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
className?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const DEFAULT_STARTERS = ["What can you help me with?", "Brainstorm a few ideas with me", "Walk me through how you work"] as const;
|
|
16
|
+
|
|
17
|
+
function DefaultAvatar({ name }: { name: string }) {
|
|
18
|
+
const initial = (name.trim()[0] ?? "?").toUpperCase();
|
|
19
|
+
return (
|
|
20
|
+
<div className="flex size-16 items-center justify-center rounded-full bg-brand/20 text-xl font-semibold text-brand-700" aria-hidden>
|
|
21
|
+
{initial}
|
|
22
|
+
</div>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function ChatWelcome({ name, description, avatar, starters = [...DEFAULT_STARTERS], onStarter, disabled, className }: ChatWelcomeProps) {
|
|
27
|
+
const desc = description?.trim() || null;
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className={cn("nonla-chat-welcome flex flex-col items-center justify-center min-h-full w-full px-6 py-10 text-center", className)}>
|
|
31
|
+
<div className="relative mb-5">
|
|
32
|
+
<div
|
|
33
|
+
aria-hidden
|
|
34
|
+
className="pointer-events-none absolute left-1/2 top-1/2 size-28 -translate-x-1/2 -translate-y-1/2 rounded-full"
|
|
35
|
+
style={{
|
|
36
|
+
background: "radial-gradient(circle, color-mix(in oklab, var(--brand) 28%, transparent) 0%, transparent 70%)",
|
|
37
|
+
}}
|
|
38
|
+
/>
|
|
39
|
+
<div
|
|
40
|
+
className="relative rounded-full p-0.5"
|
|
41
|
+
style={{
|
|
42
|
+
background: "linear-gradient(145deg, color-mix(in oklab, var(--brand-600) 55%, transparent), transparent 60%)",
|
|
43
|
+
}}
|
|
44
|
+
>
|
|
45
|
+
<div className="rounded-full bg-popover p-0.5">{avatar ?? <DefaultAvatar name={name} />}</div>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
|
|
49
|
+
<h2 className="m-0 text-[22px] font-semibold tracking-tight text-foreground leading-snug">Hi, I'm {name}</h2>
|
|
50
|
+
|
|
51
|
+
{desc ? <p className="mt-2 mb-0 max-w-90 text-[13px] leading-relaxed text-tertiary-foreground line-clamp-2">{desc}</p> : <p className="mt-2 mb-0 max-w-[320px] text-[13px] leading-relaxed text-tertiary-foreground">Send a message to start working together.</p>}
|
|
52
|
+
|
|
53
|
+
{starters.length > 0 && onStarter ? (
|
|
54
|
+
<div className="mt-8 flex flex-wrap items-center justify-center gap-2 max-w-130">
|
|
55
|
+
{starters.map((text) => (
|
|
56
|
+
<button
|
|
57
|
+
key={text}
|
|
58
|
+
type="button"
|
|
59
|
+
disabled={disabled}
|
|
60
|
+
onClick={() => onStarter(text)}
|
|
61
|
+
className="px-3 py-1.5 rounded-lg border border-border bg-transparent text-[12px] font-medium text-tertiary-foreground cursor-pointer transition-colors duration-150 font-[inherit] hover:border-border hover:bg-muted/60 hover:text-foreground disabled:opacity-40 disabled:cursor-not-allowed"
|
|
62
|
+
>
|
|
63
|
+
{text}
|
|
64
|
+
</button>
|
|
65
|
+
))}
|
|
66
|
+
</div>
|
|
67
|
+
) : null}
|
|
68
|
+
</div>
|
|
69
|
+
);
|
|
70
|
+
}
|