Repository navigation
[ZEPPELIN-6742] Connect the AI Assistant frontend to conversation REST and WebSocket APIs - #5563
Conversation
2aaa236 to
b75de05
Compare
b75de05 to
c470802
Compare
|
This PR connects the frontend to the conversation REST API and WebSocket run events. The current diff is +1,271/-8 across five files. Excluding 795 lines in Server PR #5547, #5558, and #5561 have merged, and this PR targets |
b772a8d to
9f119fc
Compare
0c3b303 to
c41de73
Compare
9f119fc to
b0b71a0
Compare
c41de73 to
3af8991
Compare
b0b71a0 to
fc57755
Compare
3af8991 to
3bcb8e2
Compare
fc57755 to
404c586
Compare
3bcb8e2 to
e3e2119
Compare
404c586 to
e345f98
Compare
e3e2119 to
c221c82
Compare
c221c82 to
e4880f9
Compare
There was a problem hiding this comment.
Transport-level review. I reproduced the reported sequences by loading the module and controlling HTTP responses and socket events. I have not verified them against a live server or the notebook UI. The authentication comment is a contract question, not a confirmed integration defect.
| // ACKs belong to the connection, even if the note or conversation that sent the request has unmounted. | ||
| const trackPendingRun = (socket: AssistantSocket, conversationId: string, tracking: RunTracking): PendingRun => { | ||
| const pending: PendingRun = { abandoned: false }; | ||
| tracking.disconnected.delete(conversationId); |
There was a problem hiding this comment.
This removes the previous disconnected marker before the new request has been accepted.
I reproduced this sequence: run A starts, its connection closes, and a retry is sent without reloading history. When that retry receives run.failed with status 409, the transport reports idle, even though A's history has not been recovered. The failure message itself says the conversation is still answering.
This conflicts with the documented behavior of retaining the lost-connection state until history is reloaded. Please preserve that uncertainty when the retry is rejected, and cover the disconnect → retry → 409 sequence in a regression test.
There was a problem hiding this comment.
| {}, | ||
| onAuthError | ||
| ); | ||
| if (!before && runs.disconnected.delete(conversationId)) notifyRunState(runs, conversationId); |
There was a problem hiding this comment.
A successful latest-page response clears the current disconnected marker without checking whether the request predates that disconnect.
I reproduced this with an accepted run: start getMessages(), delay its response, close the socket, then release the history response. The state changes from disconnected to idle, although no history request was started after the connection loss.
Please ensure that a request spanning a newer disconnect cannot clear that disconnect's recovery state, and add a regression test for this ordering.
There was a problem hiding this comment.
7b16eb1 to
3e95adf
Compare
e4880f9 to
6de3a98
Compare
698542f to
a3acd8e
Compare
| ...later, | ||
| content: [earlier.content, later.content].filter(Boolean).join('\n\n'), | ||
| ...(toolCalls.length ? { toolCalls } : {}) | ||
| segments |
There was a problem hiding this comment.
These parts seem to conflict with the changes in #5561
| typeof value === 'object' && value !== null && 'body' in value ? value.body : value; | ||
|
|
||
| /** Hands 401/405 to the host, which owns login redirects and logout. */ | ||
| export type AssistantAuthErrorHandler = (status: number, location: string | null) => void; |
There was a problem hiding this comment.
The server doesn’t provide a redirect location. I’d prefer to leave unimplemented features out of the contract. What do you think?
There was a problem hiding this comment.
You're right. Nothing on the server sets Location on a 401: form auth redirects to /api/login with a 302 that fetch follows to a 405, and Kerberos returns a 401 with only WWW-Authenticate. I narrowed the handler to (status: number) => void and dropped location from the error in d098067e5.
| disconnected: Map<string, object>; | ||
| listeners: Map<string, Set<(state: AssistantRunState) => void>>; | ||
| stopTracking?: () => void; | ||
| } |
There was a problem hiding this comment.
I wonder if we could simplify the per-conversation state tracking a bit. One concern is that a client-side timeout leaves pending and active intact. If no terminal event arrives and the WebSocket stays connected, the conversation could remain blocked.
Some of this complexity seems to come from sharing the notebook WebSocket, where events can keep arriving after the user leaves the view. A separate assistant WebSocket tied to the component lifecycle might simplify this eventually.
One approach we could consider for this PR, without changing the server, is:
-
Keep a single slot for the current request:
for example: :{ conversationId, phase: 'pending' | 'running', runId? }
The server already rejects concurrent requests in the same conversation with 409. We could ignore events whose conversationId does not match the slot, and clear both the slot and its subscription when the component unmounts. -
Track the remaining edge case separately:
A user sendshiin conversationC1, leaves the page, then returns toC1and sendsbye.An event fromhicould arrive while the client is waiting forbyeand be mistaken for its response.
The single-slot approach would not fully handle this timing-dependent case, but it seems unlikely to happen often. Could we open a separate issue for it and address it later?
There was a problem hiding this comment.
Thanks, done in 42f456587. Each run now keeps only its own { conversationId, phase, runId? }, drops events for other conversations, and releases its subscriptions when it ends. Duplicate sends are left to the server's 409, and the abandoned/disconnected tracking is gone. After rebasing onto master, I also dropped the now unused AssistantRunState and subscribeRunState from the contract in a5127fe31.
Could you open an issue for 2? Retrying after a client timeout and losing events after a reconnect share the same cause, so it would be good to include them too.
There was a problem hiding this comment.
One additional case for the follow-up issue: if two openRun() calls for the same conversation are pending at the same time, both can accept the first run.started and bind to the same runId. The second request’s 409 run.failed may then be ignored. This seems to be another consequence of not being able to correlate a client request with its initial server response. Could we include this scenario as well?
There was a problem hiding this comment.
@voidmatcha @tbonelee
Created a follow-up issue: https://issues.apache.org/jira/browse/ZEPPELIN-6753
| return active && !sessionSignal?.aborted; | ||
| }, | ||
| transport, | ||
| setActive(next: boolean) { |
There was a problem hiding this comment.
Could you clarify which UX scenario requires the same scopeTransport to be deactivated and later reactivated? Managing a reactivatable active flag alongside the permanent sessionSignal and per-run signals seems to add lifecycle complexity.
It also leaves an edge case where a REST request started before setActive(false) can finish after setActive(true) and still pass guarded(). If this is for panel visibility, that could perhaps be managed separately. If it is for revisiting a note, we could cache its UI state and recreate the lightweight transport instead.
If reactivation is not required, would a one-shot per-note scope with an AbortSignal for both REST and WebSocket work be simpler?
| // The server names an untitled conversation by its creation time. | ||
| const titleFor = (title?: string) => { | ||
| const trimmed = title?.trim().replace(/\s+/g, ' '); | ||
| return trimmed ? { title: trimmed.length > 80 ? `${trimmed.slice(0, 79)}…` : trimmed } : {}; |
There was a problem hiding this comment.
Is this normalization and 80-character limit intended to be a UI presentation rule or a conversation invariant? The server does not appear to enforce the same policy, so stored titles can differ depending on the client. If it is an invariant, could it be enforced server-side as well? If it is only presentational, truncating before persistence may discard part of the original title.
|
@tbonelee Thanks for the careful review. I made the per-note transport scope one-shot in 23880cd1a and removed client-side title normalization and truncation in 34034fc04. I agree that concurrent |
9df7e4d to
34034fc
Compare
| listConversations(signal?: AbortSignal): Promise<AssistantConversation[]>; | ||
| createConversation(body: { title?: string }, signal?: AbortSignal): Promise<AssistantConversation>; | ||
| deleteConversation(conversationId: string, signal?: AbortSignal): Promise<void>; | ||
| /** The latest page of history, or the page before `before` (a page cursor). */ | ||
| getMessages(conversationId: string, before?: string): Promise<AssistantMessagePage>; | ||
| /** Observe local run state, including a lost connection until history is reloaded. */ | ||
| subscribeRunState?(conversationId: string, listener: (state: AssistantRunState) => void): () => void; | ||
| getMessages(conversationId: string, before?: string, signal?: AbortSignal): Promise<AssistantMessagePage>; |
There was a problem hiding this comment.
Is there a specific reason these REST calls need AbortSignal, unlike our other API calls? I understand its role in stopping the WebSocket event loop, but I’m not sure we need it for these regular REST requests as well.
| subscribeRunState?(conversationId: string, listener: (state: AssistantRunState) => void): () => void; | ||
| getMessages(conversationId: string, before?: string, signal?: AbortSignal): Promise<AssistantMessagePage>; | ||
| /** Aborting stops local event consumption; it does not cancel a run already accepted by the server. */ | ||
| openRun(conversationId: string, body: AssistantRunBody, signal: AbortSignal): AsyncIterable<AssistantRunEvent>; |
There was a problem hiding this comment.
Would it be okay to omit the GET /{conversationId} API?
| const toConversation = (conversation: ConversationSummary): AssistantConversation => { | ||
| return { | ||
| id: conversation.id, | ||
| title: conversation.title, | ||
| ownerId: conversation.ownerId, | ||
| canSendMessage: conversation.canSendMessage, | ||
| updatedAt: conversation.updatedAt | ||
| }; | ||
| }; |
There was a problem hiding this comment.
Could we simplify this to:
const toConversation = (conversation: ConversationSummary): AssistantConversation => ({ ...conversation });
Also, are these types intended to separate the server response from the UI model? If so, could we name this function more clearly to reflect that?
|
@voidmatcha For example, I’m not sure the REST calls need the same lifecycle handling in |
|
@seung-00 Thank you for the thoughtful review. I separated the conversation REST API from WebSocket runs. I removed The current frontend flow needs I renamed the server response type to |
Align the temporary integration branch with PR #5563's split conversation API and run stream. Preserve the assembled Assistant UI from the existing integration branch. Confidence: high Scope-risk: fixture panel work based on the old transport needs the new contract Not-tested: browser end-to-end Assistant flow
|
Merged into master (d01a1c3). |
What is this PR for?
Connect the Assistant frontend to the conversation REST and WebSocket APIs. The transport provides conversation creation and retrieval, message sending, and response delivery for the UI.
Server PR #5547, #5558, and UI contracts #5561 have merged. This PR is now based on master.
What type of PR is it?
Feature
Todos
What is the Jira issue?
ZEPPELIN-6742
How should this be tested?
Run from
zeppelin-web-angular/:npm run build-project:sdk npm run typecheck:react npm --prefix projects/zeppelin-react test -- src/entities/assistant/model/assistantTransport.spec.tsAlso verify the REST and WebSocket integration against the final API contract from server PR #5547.
Screenshots (if appropriate)
N/A
Questions: