@hef2024/llmasaservice-ui 0.23.2 → 0.24.1
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/dist/index.css +284 -0
- package/dist/index.d.mts +5 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +86 -30
- package/dist/index.mjs +86 -30
- package/package.json +1 -1
- package/src/AIChatPanel.css +178 -0
- package/src/AIChatPanel.tsx +30 -20
- package/src/ChatPanel.css +178 -0
- package/src/ChatPanel.tsx +89 -21
- package/src/components/ui/ThinkingBlock.tsx +22 -8
package/src/ChatPanel.tsx
CHANGED
|
@@ -315,6 +315,13 @@ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
|
|
|
315
315
|
const [iframeUrl, setIframeUrl] = useState<string | null>(null);
|
|
316
316
|
const responseAreaRef = useRef(null);
|
|
317
317
|
|
|
318
|
+
// Auto-scroll state - tracks if user has manually scrolled during streaming
|
|
319
|
+
const [userHasScrolled, setUserHasScrolled] = useState(false);
|
|
320
|
+
const lastScrollTopRef = useRef<number>(0);
|
|
321
|
+
const prevResponseLengthRef = useRef<number>(0);
|
|
322
|
+
const userHasScrolledRef = useRef<boolean>(false);
|
|
323
|
+
const idleRef = useRef<boolean>(true);
|
|
324
|
+
|
|
318
325
|
// Memoized regex patterns to avoid recreation on every render
|
|
319
326
|
const THINKING_PATTERNS = useMemo(
|
|
320
327
|
() => ({
|
|
@@ -2134,34 +2141,91 @@ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
|
|
|
2134
2141
|
}
|
|
2135
2142
|
}, [initialPrompt]);
|
|
2136
2143
|
|
|
2144
|
+
// Keep refs in sync with state
|
|
2145
|
+
useEffect(() => {
|
|
2146
|
+
userHasScrolledRef.current = userHasScrolled;
|
|
2147
|
+
}, [userHasScrolled]);
|
|
2148
|
+
|
|
2149
|
+
useEffect(() => {
|
|
2150
|
+
idleRef.current = idle;
|
|
2151
|
+
}, [idle]);
|
|
2152
|
+
|
|
2153
|
+
// Auto-scroll to bottom during streaming (when user hasn't scrolled up)
|
|
2137
2154
|
useEffect(() => {
|
|
2138
|
-
if (
|
|
2155
|
+
// Skip if idle (not streaming)
|
|
2156
|
+
if (idle) return;
|
|
2157
|
+
|
|
2158
|
+
const currentResponseLength = response.length;
|
|
2159
|
+
const responseGotLonger = currentResponseLength > prevResponseLengthRef.current;
|
|
2160
|
+
prevResponseLengthRef.current = currentResponseLength;
|
|
2161
|
+
|
|
2162
|
+
// Only auto-scroll if user hasn't manually scrolled up (or scrollToEnd prop forces it)
|
|
2163
|
+
const shouldAutoScroll = scrollToEnd || !userHasScrolledRef.current;
|
|
2164
|
+
if (shouldAutoScroll && response && responseGotLonger) {
|
|
2139
2165
|
if (window.top !== window.self) {
|
|
2140
2166
|
const responseArea = responseAreaRef.current as any;
|
|
2141
|
-
responseArea
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2167
|
+
if (responseArea) {
|
|
2168
|
+
responseArea.scrollTo({
|
|
2169
|
+
top: responseArea.scrollHeight,
|
|
2170
|
+
behavior: "auto",
|
|
2171
|
+
});
|
|
2172
|
+
}
|
|
2145
2173
|
} else {
|
|
2146
|
-
|
|
2147
|
-
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
|
|
2174
|
+
bottomRef.current?.scrollIntoView({ behavior: "auto", block: "end" });
|
|
2148
2175
|
}
|
|
2149
|
-
}
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2176
|
+
}
|
|
2177
|
+
}, [response, idle, scrollToEnd]);
|
|
2178
|
+
|
|
2179
|
+
// Detect user scroll intent via wheel event (fires before scroll position changes)
|
|
2180
|
+
useEffect(() => {
|
|
2181
|
+
const responseArea = responseAreaRef.current as any;
|
|
2182
|
+
if (!responseArea) return;
|
|
2183
|
+
|
|
2184
|
+
// Wheel event detects user intent immediately (before scroll position changes)
|
|
2185
|
+
const handleWheel = (e: WheelEvent) => {
|
|
2186
|
+
// Skip if not streaming
|
|
2187
|
+
if (idleRef.current) return;
|
|
2188
|
+
|
|
2189
|
+
// deltaY < 0 means scrolling UP (toward top of document)
|
|
2190
|
+
if (e.deltaY < 0 && !userHasScrolledRef.current) {
|
|
2191
|
+
setUserHasScrolled(true);
|
|
2192
|
+
}
|
|
2193
|
+
};
|
|
2194
|
+
|
|
2195
|
+
// Scroll event for hasScroll/isAtBottom UI and detecting return to bottom
|
|
2196
|
+
const handleScroll = () => {
|
|
2197
|
+
// Update hasScroll and isAtBottom states
|
|
2198
|
+
setHasScroll(hasVerticalScrollbar(responseArea));
|
|
2199
|
+
const isScrolledToBottom =
|
|
2200
|
+
responseArea.scrollHeight - responseArea.scrollTop - responseArea.clientHeight < 5;
|
|
2201
|
+
setIsAtBottom(isScrolledToBottom);
|
|
2202
|
+
|
|
2203
|
+
// Re-enable auto-scroll when user scrolls back near bottom (during streaming)
|
|
2204
|
+
if (!idleRef.current && userHasScrolledRef.current) {
|
|
2205
|
+
const isNearBottom =
|
|
2206
|
+
responseArea.scrollHeight - responseArea.scrollTop - responseArea.clientHeight < 50;
|
|
2207
|
+
if (isNearBottom) {
|
|
2208
|
+
setUserHasScrolled(false);
|
|
2209
|
+
}
|
|
2162
2210
|
}
|
|
2211
|
+
};
|
|
2212
|
+
|
|
2213
|
+
handleScroll(); // Initial check
|
|
2214
|
+
responseArea.addEventListener("wheel", handleWheel, { passive: true });
|
|
2215
|
+
responseArea.addEventListener("scroll", handleScroll, { passive: true });
|
|
2216
|
+
return () => {
|
|
2217
|
+
responseArea.removeEventListener("wheel", handleWheel);
|
|
2218
|
+
responseArea.removeEventListener("scroll", handleScroll);
|
|
2219
|
+
};
|
|
2220
|
+
}, []); // Empty deps - uses refs
|
|
2221
|
+
|
|
2222
|
+
// Update hasScroll when history changes
|
|
2223
|
+
useEffect(() => {
|
|
2224
|
+
const responseArea = responseAreaRef.current as any;
|
|
2225
|
+
if (responseArea) {
|
|
2226
|
+
setHasScroll(hasVerticalScrollbar(responseArea));
|
|
2163
2227
|
}
|
|
2164
|
-
}, [
|
|
2228
|
+
}, [history]);
|
|
2165
2229
|
|
|
2166
2230
|
// Use ref to avoid infinite loops from unstable callback references
|
|
2167
2231
|
useEffect(() => {
|
|
@@ -2337,6 +2401,10 @@ const ChatPanel: React.FC<ChatPanelProps & ExtraProps> = ({
|
|
|
2337
2401
|
// Clear any previous errors
|
|
2338
2402
|
setError(null);
|
|
2339
2403
|
|
|
2404
|
+
// Reset scroll tracking for new message - enable auto-scroll
|
|
2405
|
+
setUserHasScrolled(false);
|
|
2406
|
+
prevResponseLengthRef.current = 0;
|
|
2407
|
+
|
|
2340
2408
|
// IMPORTANT: Clear the response BEFORE setting new lastKey
|
|
2341
2409
|
// This prevents the old response from being written to the new history entry
|
|
2342
2410
|
// when the history update effect runs
|
|
@@ -102,6 +102,11 @@ const getDefaultTitle = (type: ThinkingBlockType): string => {
|
|
|
102
102
|
/**
|
|
103
103
|
* ThinkingBlock - A collapsible block for displaying thinking/reasoning/searching content
|
|
104
104
|
* with streaming support. Content streams in naturally as it arrives.
|
|
105
|
+
*
|
|
106
|
+
* Features Cursor-style effects:
|
|
107
|
+
* - Spinning icon when streaming
|
|
108
|
+
* - Shimmer highlight effect on title text
|
|
109
|
+
* - Compact collapsed state with content preview
|
|
105
110
|
*/
|
|
106
111
|
export const ThinkingBlock: React.FC<ThinkingBlockProps> = ({
|
|
107
112
|
type,
|
|
@@ -113,10 +118,17 @@ export const ThinkingBlock: React.FC<ThinkingBlockProps> = ({
|
|
|
113
118
|
}) => {
|
|
114
119
|
const displayTitle = title || getDefaultTitle(type);
|
|
115
120
|
const icon = getIcon(type);
|
|
121
|
+
|
|
122
|
+
// Create a truncated preview for collapsed state
|
|
123
|
+
const getPreview = (text: string, maxLength: number = 60): string => {
|
|
124
|
+
const cleaned = text.replace(/\s+/g, ' ').trim();
|
|
125
|
+
if (cleaned.length <= maxLength) return cleaned;
|
|
126
|
+
return cleaned.substring(0, maxLength).trim() + '...';
|
|
127
|
+
};
|
|
116
128
|
|
|
117
129
|
return (
|
|
118
130
|
<div
|
|
119
|
-
className={`thinking-block thinking-block--${type} ${isCollapsed ? 'thinking-block--collapsed' : ''}`}
|
|
131
|
+
className={`thinking-block thinking-block--${type} ${isCollapsed ? 'thinking-block--collapsed' : ''} ${isStreaming ? 'thinking-block--streaming' : ''}`}
|
|
120
132
|
>
|
|
121
133
|
<button
|
|
122
134
|
className="thinking-block__header"
|
|
@@ -125,13 +137,15 @@ export const ThinkingBlock: React.FC<ThinkingBlockProps> = ({
|
|
|
125
137
|
aria-expanded={!isCollapsed}
|
|
126
138
|
>
|
|
127
139
|
<div className="thinking-block__header-left">
|
|
128
|
-
{
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
140
|
+
<span className={`thinking-block__icon-wrapper ${isStreaming ? 'thinking-block__icon-wrapper--spinning' : ''}`}>
|
|
141
|
+
{icon}
|
|
142
|
+
</span>
|
|
143
|
+
<span className={`thinking-block__title ${isStreaming ? 'thinking-block__title--shimmer' : ''}`}>
|
|
144
|
+
{displayTitle}
|
|
145
|
+
</span>
|
|
146
|
+
{isCollapsed && content && (
|
|
147
|
+
<span className="thinking-block__preview">
|
|
148
|
+
{getPreview(content)}
|
|
135
149
|
</span>
|
|
136
150
|
)}
|
|
137
151
|
</div>
|