Skip to content

Commit 0837fa0

Browse files
committed
Fix streaming error handling, attempt to fix performance issues, and add tests
1 parent e1806e1 commit 0837fa0

File tree

11 files changed

+1336
-28
lines changed

11 files changed

+1336
-28
lines changed

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ and this project adheres to
2323
[#3701](https://github.com/OpenFn/lightning/issues/3701)
2424
- Delete nodes from Job panel in Collaborative Editor
2525
[#3702](https://github.com/OpenFn/lightning/issues/3702)
26+
- **AI Assistant Streaming**: AI responses now stream in real-time with status updates
27+
- Users see AI responses appear word-by-word as they're generated
28+
- Status indicators show thinking progress (e.g., "Researching...", "Generating code...")
29+
- Automatic error recovery with retry/cancel options
30+
- Configurable timeout based on Apollo settings
31+
[#3585](https://github.com/OpenFn/lightning/issues/3585)
2632

2733
### Changed
2834

@@ -38,6 +44,13 @@ and this project adheres to
3844
unauthorized edits when user roles change during active collaboration sessions
3945
[#3749](https://github.com/OpenFn/lightning/issues/3749)
4046

47+
### Technical
48+
49+
- Added `Lightning.ApolloClient.SSEStream` for Server-Sent Events handling
50+
- Enhanced `MessageProcessor` to support streaming responses
51+
- Updated AI Assistant component with real-time markdown rendering
52+
- Improved error handling for network failures and timeouts
53+
4154
## [2.14.11] - 2025-10-15
4255

4356
## [2.14.11-pre1] - 2025-10-15

assets/js/hooks/index.ts

Lines changed: 142 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import tippy, {
44
} from 'tippy.js';
55
import { format, formatRelative } from 'date-fns';
66
import { enUS } from 'date-fns/locale';
7+
import { marked } from 'marked';
78
import type { PhoenixHook } from './PhoenixHook';
89

910
import LogLineHighlight from './LogLineHighlight';
@@ -684,9 +685,39 @@ export const BlurDataclipEditor = {
684685

685686
export const ScrollToMessage = {
686687
mounted() {
688+
this.shouldAutoScroll = true;
689+
690+
// Throttle scroll tracking to reduce CPU usage
691+
this.handleScrollThrottled = this.throttle(() => {
692+
const isAtBottom = this.isAtBottom();
693+
this.shouldAutoScroll = isAtBottom;
694+
}, 100); // Only check every 100ms
695+
696+
this.el.addEventListener('scroll', this.handleScrollThrottled);
687697
this.handleScroll();
688698
},
689699

700+
destroyed() {
701+
if (this.handleScrollThrottled) {
702+
this.el.removeEventListener('scroll', this.handleScrollThrottled);
703+
}
704+
if (this.throttleTimeout !== undefined) {
705+
clearTimeout(this.throttleTimeout);
706+
}
707+
},
708+
709+
throttle(func: () => void, wait: number): () => void {
710+
return () => {
711+
if (this.throttleTimeout !== undefined) {
712+
clearTimeout(this.throttleTimeout);
713+
}
714+
this.throttleTimeout = setTimeout(() => {
715+
func();
716+
this.throttleTimeout = undefined;
717+
}, wait) as unknown as number;
718+
};
719+
},
720+
690721
updated() {
691722
this.handleScroll();
692723
},
@@ -696,7 +727,8 @@ export const ScrollToMessage = {
696727

697728
if (targetMessageId) {
698729
this.scrollToSpecificMessage(targetMessageId);
699-
} else {
730+
} else if (this.shouldAutoScroll) {
731+
// Only auto-scroll if user hasn't manually scrolled up
700732
this.scrollToBottom();
701733
}
702734
},
@@ -717,18 +749,26 @@ export const ScrollToMessage = {
717749
}
718750
},
719751

752+
isAtBottom() {
753+
const threshold = 50; // pixels from bottom
754+
const position = this.el.scrollTop + this.el.clientHeight;
755+
const height = this.el.scrollHeight;
756+
return height - position <= threshold;
757+
},
758+
720759
scrollToBottom() {
721-
setTimeout(() => {
722-
this.el.scrollTo({
723-
top: this.el.scrollHeight,
724-
behavior: 'smooth',
725-
});
726-
}, 600);
760+
// Use instant scroll during updates to prevent jank
761+
this.el.scrollTop = this.el.scrollHeight;
727762
},
728763
} as PhoenixHook<{
764+
shouldAutoScroll: boolean;
765+
handleScrollThrottled?: () => void;
766+
throttleTimeout?: number;
767+
throttle: (func: () => void, wait: number) => () => void;
729768
handleScroll: () => void;
730769
scrollToSpecificMessage: (messageId: string) => void;
731770
scrollToBottom: () => void;
771+
isAtBottom: () => boolean;
732772
}>;
733773

734774
export const Copy = {
@@ -1020,3 +1060,98 @@ export const LocalTimeConverter = {
10201060
convertDateTime: () => void;
10211061
convertToDisplayTime: (isoTimestamp: string, display: string) => void;
10221062
}>;
1063+
1064+
export const StreamingText = {
1065+
mounted() {
1066+
this.lastContent = '';
1067+
this.renderer = this.createCustomRenderer();
1068+
this.parseCount = 0;
1069+
this.pendingUpdate = undefined;
1070+
this.updateContent();
1071+
},
1072+
1073+
updated() {
1074+
// Debounce updates by 50ms to batch rapid chunk arrivals
1075+
if (this.pendingUpdate !== undefined) {
1076+
clearTimeout(this.pendingUpdate);
1077+
}
1078+
1079+
this.pendingUpdate = setTimeout(() => {
1080+
this.updateContent();
1081+
this.pendingUpdate = undefined;
1082+
}, 50) as unknown as number;
1083+
},
1084+
1085+
destroyed() {
1086+
if (this.pendingUpdate !== undefined) {
1087+
clearTimeout(this.pendingUpdate);
1088+
}
1089+
},
1090+
1091+
createCustomRenderer() {
1092+
const renderer = new marked.Renderer();
1093+
1094+
// Apply custom CSS classes to match backend Earmark styles
1095+
renderer.code = (code, language) => {
1096+
const lang = language ? ` class="${language}"` : '';
1097+
return `<pre class="rounded-md font-mono bg-slate-100 border-2 border-slate-200 text-slate-800 my-4 p-2 overflow-auto"><code${lang}>${code}</code></pre>`;
1098+
};
1099+
1100+
renderer.link = (href, title, text) => {
1101+
return `<a href="${href}" class="text-primary-400 hover:text-primary-600" target="_blank">${text}</a>`;
1102+
};
1103+
1104+
renderer.heading = (text, level) => {
1105+
const classes = level === 1 ? 'text-2xl font-bold mb-6' : 'text-xl font-semibold mb-4 mt-8';
1106+
return `<h${level} class="${classes}">${text}</h${level}>`;
1107+
};
1108+
1109+
renderer.list = (body, ordered) => {
1110+
const tag = ordered ? 'ol' : 'ul';
1111+
const classes = ordered ? 'list-decimal pl-8 space-y-1' : 'list-disc pl-8 space-y-1';
1112+
return `<${tag} class="${classes}">${body}</${tag}>`;
1113+
};
1114+
1115+
renderer.listitem = (text) => {
1116+
return `<li class="text-gray-800">${text}</li>`;
1117+
};
1118+
1119+
renderer.paragraph = (text) => {
1120+
return `<p class="mt-1 mb-2 text-gray-800">${text}</p>`;
1121+
};
1122+
1123+
return renderer;
1124+
},
1125+
1126+
updateContent() {
1127+
const start = performance.now();
1128+
const newContent = this.el.dataset.streamingContent || '';
1129+
1130+
if (newContent !== this.lastContent) {
1131+
this.parseCount++;
1132+
1133+
// Re-parse entire content as markdown
1134+
// This handles split ticks because we always parse the full accumulated string
1135+
const htmlContent = marked.parse(newContent, {
1136+
renderer: this.renderer,
1137+
breaks: true,
1138+
gfm: true,
1139+
});
1140+
1141+
this.el.innerHTML = htmlContent;
1142+
this.lastContent = newContent;
1143+
1144+
const duration = performance.now() - start;
1145+
console.debug(
1146+
`[StreamingText] Parse #${this.parseCount}: ${duration.toFixed(2)}ms for ${newContent.length} chars`
1147+
);
1148+
}
1149+
},
1150+
} as PhoenixHook<{
1151+
lastContent: string;
1152+
renderer: marked.Renderer;
1153+
parseCount: number;
1154+
pendingUpdate?: number;
1155+
createCustomRenderer: () => marked.Renderer;
1156+
updateContent: () => void;
1157+
}>;

lib/lightning/ai_assistant/ai_assistant.ex

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -569,12 +569,16 @@ defmodule Lightning.AiAssistant do
569569
570570
## Returns
571571
572-
List of `%ChatMessage{}` structs with `:role` of `:user` and `:status` of `:pending`.
572+
List of `%ChatMessage{}` structs with `:role` of `:user` and `:status` of `:pending` or `:processing`.
573573
"""
574574
@spec find_pending_user_messages(ChatSession.t()) :: [ChatMessage.t()]
575575
def find_pending_user_messages(session) do
576576
messages = session.messages || []
577-
Enum.filter(messages, &(&1.role == :user && &1.status == :pending))
577+
578+
Enum.filter(
579+
messages,
580+
&(&1.role == :user && &1.status in [:pending, :processing])
581+
)
578582
end
579583

580584
@doc """

0 commit comments

Comments
 (0)