Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
5ec8ff0
add apollo streaming support
hanna-paasivirta Sep 23, 2025
42be554
Add basic streaming support to ai assistant component
hanna-paasivirta Sep 25, 2025
f7d1b50
update message processor
hanna-paasivirta Sep 25, 2025
2563ffa
replace mock websocket
hanna-paasivirta Sep 30, 2025
01e3137
correct payload
hanna-paasivirta Sep 30, 2025
54d1447
add partial streaming in ui
hanna-paasivirta Oct 2, 2025
3bbfd7d
fix disappearing message
hanna-paasivirta Oct 2, 2025
98aa0bd
add typing animation
hanna-paasivirta Oct 2, 2025
ecc63b4
remove old attempts
hanna-paasivirta Oct 2, 2025
42afb2d
add workflow chat
hanna-paasivirta Oct 7, 2025
aeb778f
add new line at the end of bin/boostrap
elias-ba Oct 6, 2025
d54f08e
change to sse
hanna-paasivirta Oct 8, 2025
133b6d5
tweak sse format
hanna-paasivirta Oct 9, 2025
9048d76
smooth text
hanna-paasivirta Oct 13, 2025
b28d03f
add code block and missing adaptor context
hanna-paasivirta Oct 14, 2025
31c5ed9
handle payload
hanna-paasivirta Oct 14, 2025
5a47e29
fix pending status
hanna-paasivirta Oct 14, 2025
d93d7fd
fix greyed save button
hanna-paasivirta Oct 15, 2025
96b61b9
avoid status reset
hanna-paasivirta Oct 15, 2025
cb53b98
Fix streaming error handling, attempt to fix performance issues, and …
elias-ba Oct 21, 2025
ce0a610
Fix Dialyzer typespec errors
elias-ba Oct 21, 2025
4d0523f
Fix Credo warnings
elias-ba Oct 21, 2025
d52e337
Reduce cyclomatic complexity
elias-ba Oct 21, 2025
ea5d504
Fix Mox expectations for SSEStream in tests
elias-ba Oct 21, 2025
08a2365
test: improve AI Assistant test synchronization
elias-ba Oct 21, 2025
bcfd66b
test: improve SSEStream test coverage
elias-ba Oct 21, 2025
29dc6c0
test: add full streaming error UI tests with Finch mocking
elias-ba Oct 21, 2025
1b114e9
fix: move Mimic.set_mimic_global to individual tests
elias-ba Oct 21, 2025
1289602
fix: isolate Finch mocking to prevent test interference
elias-ba Oct 21, 2025
5f7ccd6
fix: resolve SSEStream timeout test flakiness when running full suite
elias-ba Oct 21, 2025
88de798
fix: handle timing differences in SSEStream tests for CI environment
elias-ba Oct 21, 2025
945b890
test: add coverage for successful SSEStream paths and error handling
elias-ba Oct 21, 2025
6f45919
Mix format
elias-ba Oct 21, 2025
94fa461
test: improve MessageProcessor coverage to 98.8%
elias-ba Oct 21, 2025
c284931
refactor: rename message_processor_comprehensive_test to message_proc…
elias-ba Oct 21, 2025
325b249
style: format MessageProcessor test file
elias-ba Oct 21, 2025
6e7a937
test: add streaming edge case tests for component.ex coverage
elias-ba Oct 21, 2025
88910f3
fix streaming error state rendering
elias-ba Oct 21, 2025
cd54add
add test for pending operation during session switch
elias-ba Oct 21, 2025
3c7a3e7
Add comprehensive integration tests for AI assistant component
elias-ba Oct 21, 2025
e906608
Consolidate AI assistant tests into single file
elias-ba Oct 21, 2025
6a560d1
Remove meaningless smoke tests that don't verify behavior
elias-ba Oct 21, 2025
568845a
Remove fake streaming error documentation tests
elias-ba Oct 21, 2025
2e7eca1
Remove .context from version control
elias-ba Oct 22, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ and this project adheres to
[#3702](https://github.com/OpenFn/lightning/issues/3702)
- Reintroduce the impeded project with hopefully better performance
characteristics [#3542](https://github.com/OpenFn/lightning/issues/3542)
- **AI Assistant Streaming**: AI responses now stream in real-time with status updates
- Users see AI responses appear word-by-word as they're generated
- Status indicators show thinking progress (e.g., "Researching...", "Generating code...")
- Automatic error recovery with retry/cancel options
- Configurable timeout based on Apollo settings
[#3585](https://github.com/OpenFn/lightning/issues/3585)

### Changed

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

### Technical

- Added `Lightning.ApolloClient.SSEStream` for Server-Sent Events handling
- Enhanced `MessageProcessor` to support streaming responses
- Updated AI Assistant component with real-time markdown rendering
- Improved error handling for network failures and timeouts

## [2.14.11] - 2025-10-15

## [2.14.11-pre1] - 2025-10-15
Expand Down
143 changes: 136 additions & 7 deletions assets/js/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import tippy, {
} from 'tippy.js';
import { format, formatRelative } from 'date-fns';
import { enUS } from 'date-fns/locale';
import { marked } from 'marked';
import type { PhoenixHook } from './PhoenixHook';

import LogLineHighlight from './LogLineHighlight';
Expand Down Expand Up @@ -684,9 +685,38 @@ export const BlurDataclipEditor = {

export const ScrollToMessage = {
mounted() {
this.shouldAutoScroll = true;

this.handleScrollThrottled = this.throttle(() => {
const isAtBottom = this.isAtBottom();
this.shouldAutoScroll = isAtBottom;
}, 100);

this.el.addEventListener('scroll', this.handleScrollThrottled);
this.handleScroll();
},

destroyed() {
if (this.handleScrollThrottled) {
this.el.removeEventListener('scroll', this.handleScrollThrottled);
}
if (this.throttleTimeout !== undefined) {
clearTimeout(this.throttleTimeout);
}
},

throttle(func: () => void, wait: number): () => void {
return () => {
if (this.throttleTimeout !== undefined) {
clearTimeout(this.throttleTimeout);
}
this.throttleTimeout = setTimeout(() => {
func();
this.throttleTimeout = undefined;
}, wait) as unknown as number;
};
},

updated() {
this.handleScroll();
},
Expand All @@ -696,7 +726,7 @@ export const ScrollToMessage = {

if (targetMessageId) {
this.scrollToSpecificMessage(targetMessageId);
} else {
} else if (this.shouldAutoScroll) {
this.scrollToBottom();
}
},
Expand All @@ -717,18 +747,25 @@ export const ScrollToMessage = {
}
},

isAtBottom() {
const threshold = 50;
const position = this.el.scrollTop + this.el.clientHeight;
const height = this.el.scrollHeight;
return height - position <= threshold;
},

scrollToBottom() {
setTimeout(() => {
this.el.scrollTo({
top: this.el.scrollHeight,
behavior: 'smooth',
});
}, 600);
this.el.scrollTop = this.el.scrollHeight;
},
} as PhoenixHook<{
shouldAutoScroll: boolean;
handleScrollThrottled?: () => void;
throttleTimeout?: number;
throttle: (func: () => void, wait: number) => () => void;
handleScroll: () => void;
scrollToSpecificMessage: (messageId: string) => void;
scrollToBottom: () => void;
isAtBottom: () => boolean;
}>;

export const Copy = {
Expand Down Expand Up @@ -1020,3 +1057,95 @@ export const LocalTimeConverter = {
convertDateTime: () => void;
convertToDisplayTime: (isoTimestamp: string, display: string) => void;
}>;

export const StreamingText = {
mounted() {
this.lastContent = '';
this.renderer = this.createCustomRenderer();
this.parseCount = 0;
this.pendingUpdate = undefined;
this.updateContent();
},

updated() {
// Debounce updates by 50ms to batch rapid chunk arrivals
if (this.pendingUpdate !== undefined) {
clearTimeout(this.pendingUpdate);
}

this.pendingUpdate = setTimeout(() => {
this.updateContent();
this.pendingUpdate = undefined;
}, 50) as unknown as number;
},

destroyed() {
if (this.pendingUpdate !== undefined) {
clearTimeout(this.pendingUpdate);
}
},

createCustomRenderer() {
const renderer = new marked.Renderer();

renderer.code = (code, language) => {
const lang = language ? ` class="${language}"` : '';
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>`;
};

renderer.link = (href, title, text) => {
return `<a href="${href}" class="text-primary-400 hover:text-primary-600" target="_blank">${text}</a>`;
};

renderer.heading = (text, level) => {
const classes = level === 1 ? 'text-2xl font-bold mb-6' : 'text-xl font-semibold mb-4 mt-8';
return `<h${level} class="${classes}">${text}</h${level}>`;
};

renderer.list = (body, ordered) => {
const tag = ordered ? 'ol' : 'ul';
const classes = ordered ? 'list-decimal pl-8 space-y-1' : 'list-disc pl-8 space-y-1';
return `<${tag} class="${classes}">${body}</${tag}>`;
};

renderer.listitem = (text) => {
return `<li class="text-gray-800">${text}</li>`;
};

renderer.paragraph = (text) => {
return `<p class="mt-1 mb-2 text-gray-800">${text}</p>`;
};

return renderer;
},

updateContent() {
const start = performance.now();
const newContent = this.el.dataset.streamingContent || '';

if (newContent !== this.lastContent) {
this.parseCount++;

const htmlContent = marked.parse(newContent, {
renderer: this.renderer,
breaks: true,
gfm: true,
});

this.el.innerHTML = htmlContent;
this.lastContent = newContent;

const duration = performance.now() - start;
console.debug(
`[StreamingText] Parse #${this.parseCount}: ${duration.toFixed(2)}ms for ${newContent.length} chars`
);
}
},
} as PhoenixHook<{
lastContent: string;
renderer: marked.Renderer;
parseCount: number;
pendingUpdate?: number;
createCustomRenderer: () => marked.Renderer;
updateContent: () => void;
}>;
8 changes: 6 additions & 2 deletions lib/lightning/ai_assistant/ai_assistant.ex
Original file line number Diff line number Diff line change
Expand Up @@ -569,12 +569,16 @@ defmodule Lightning.AiAssistant do

## Returns

List of `%ChatMessage{}` structs with `:role` of `:user` and `:status` of `:pending`.
List of `%ChatMessage{}` structs with `:role` of `:user` and `:status` of `:pending` or `:processing`.
"""
@spec find_pending_user_messages(ChatSession.t()) :: [ChatMessage.t()]
def find_pending_user_messages(session) do
messages = session.messages || []
Enum.filter(messages, &(&1.role == :user && &1.status == :pending))

Enum.filter(
messages,
&(&1.role == :user && &1.status in [:pending, :processing])
)
end

@doc """
Expand Down
Loading