You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@zeppelin.apache.org by ch...@apache.org on 2026/10/16 17:25:41 UTC

(zeppelin) branch master updated: [ZEPPELIN-6741] Define shared AI Assistant UI contracts and message history behavior

This is an automated email from the ASF dual-hosted git repository.

tbonelee pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/zeppelin.git


The following commit(s) were added to refs/heads/master by this push:
     new c10a4a8eb3 [ZEPPELIN-6741] Define shared AI Assistant UI contracts and message history behavior
c10a4a8eb3 is described below

commit c10a4a8eb3826ec015745cec3f2eb3ca8e270bf3
Author: YONGJAE LEE (이용재) <de...@gmail.com>
AuthorDate: Sat Oct 17 02:25:34 2026 +0900

    [ZEPPELIN-6741] Define shared AI Assistant UI contracts and message history behavior
    
    ### What is this PR for?
    
    Define the shared types and message history behavior used by the Assistant UI and its host. This gives subsequent UI work a common contract to build against.
    
    Depends on #5558.
    
    ### What type of PR is it?
    
    Improvement
    
    ### Todos
    
    * [x] Merge #5558 before this PR
    * [x] Rebase onto master and retarget this PR to master before merging
    * [x] Confirm CI results
    
    ### What is the Jira issue?
    
    ZEPPELIN-6741
    
    ### How should this be tested?
    
    Run from `zeppelin-web-angular/`:
    
    ```bash
    npm run build-project:sdk
    npm run typecheck:react
    npm --prefix projects/zeppelin-react test -- src/entities/assistant/model/messageHistory.spec.ts
    ```
    
    ### Screenshots (if appropriate)
    
    N/A
    
    ### Questions:
    
    * Does the license files need to update? No
    * Is there breaking changes for older versions? No
    * Does this needs documentation? No
    
    
    Closes #5561 from voidmatcha/voidmatcha/assistant-v2-3-contracts.
    
    Signed-off-by: ChanHo Lee <ch...@apache.org>
---
 .../entities/assistant/model/assistantContract.ts  | 91 +++++++++++++++++++++
 .../assistant/model/messageHistory.spec.ts         | 95 ++++++++++++++++++++++
 .../src/entities/assistant/model/messageHistory.ts | 51 ++++++++++++
 .../src/{public-api.ts => assistant-ui.ts}         | 18 +++-
 .../projects/zeppelin-sdk/src/public-api.ts        |  1 +
 5 files changed, 252 insertions(+), 4 deletions(-)

diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/assistantContract.ts b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/assistantContract.ts
new file mode 100644
index 0000000000..e2792bdf48
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/assistantContract.ts
@@ -0,0 +1,91 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export interface AssistantConversation {
+  id: string;
+  title?: string;
+  /** Account that created the conversation, shown as "Started by …". */
+  ownerId: string;
+  /** Whether the requesting user can send messages to the conversation, as the server decides. */
+  canSendMessage: boolean;
+  /** Last activity (ISO time), shown in the conversation list. */
+  updatedAt: string;
+}
+export type AssistantMessage = AssistantUserMessage | AssistantAnswerMessage;
+export interface AssistantUserMessage {
+  id: string;
+  role: 'user';
+  content: string;
+}
+export interface AssistantAnswerMessage {
+  /** The last assistant round's id, used as the bubble key. */
+  id: string;
+  role: 'assistant';
+  /** Ordered assistant rounds as stored by the server, including tool-only rounds. */
+  segments: AssistantMessageSegment[];
+}
+export interface AssistantToolCallRef {
+  id: string;
+  name: string;
+}
+export interface AssistantMessageSegment {
+  id: string;
+  content: string;
+  toolCalls?: AssistantToolCallRef[];
+}
+/** The transport reverses the server's newest-first page so messages are oldest first. */
+export interface AssistantMessagePage {
+  messages: AssistantMessage[];
+  /** Pass as `before` to load older messages; null at the start of the conversation. */
+  cursor: string | null;
+}
+export interface AssistantRunBody {
+  content: string;
+}
+export type AssistantRunEvent =
+  | { type: 'run.started'; runId?: string }
+  | { type: 'run.completed'; runId?: string }
+  | { type: 'run.failed'; runId?: string; message: string }
+  | { type: 'message.delta'; messageId: string; delta: string }
+  | { type: 'message.done'; messageId: string; content: string }
+  | { type: 'tool_call.started'; toolCallId: string; name: string }
+  | { type: 'tool_call.done'; toolCallId: string };
+
+export type AssistantRunState = 'idle' | 'running' | 'disconnected';
+
+export interface AssistantTransport {
+  /** Conversations of the note the transport was created for. */
+  listConversations(): Promise<AssistantConversation[]>;
+  createConversation(body: { title?: string }): Promise<AssistantConversation>;
+  deleteConversation(conversationId: string): 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;
+  /** 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>;
+}
+
+export class AssistantStreamError extends Error {
+  constructor() {
+    super('The assistant stopped responding before the reply finished. Retry the request.');
+    this.name = 'AssistantStreamError';
+  }
+}
+
+/** The notebook WebSocket closed during a run; its events went with it, so only a reload shows where it got to. */
+export class AssistantConnectionError extends Error {
+  constructor() {
+    super('The connection to the server was lost. Retry to reload the conversation and see where the answer got to.');
+    this.name = 'AssistantConnectionError';
+  }
+}
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/messageHistory.spec.ts b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/messageHistory.spec.ts
new file mode 100644
index 0000000000..d378f5003a
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/messageHistory.spec.ts
@@ -0,0 +1,95 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { describe, expect, it } from 'vitest';
+
+import type { AssistantAnswerMessage, AssistantMessage } from './assistantContract';
+import { answerText, answerToolCalls, joinPages } from './messageHistory';
+
+describe('joinPages', () => {
+  it('joins a reply split across pages under the later id and retains both action logs', () => {
+    const first = { id: 'a1', content: 'Looking.', toolCalls: [{ id: 'c1', name: 'read' }] };
+    const second = { id: 'a2', content: 'Done.', toolCalls: [{ id: 'c2', name: 'summarize' }] };
+    const earlier: AssistantMessage[] = [
+      { id: 'u1', role: 'user', content: 'Read this notebook' },
+      { id: 'a1', role: 'assistant', segments: [first] }
+    ];
+    const later: AssistantMessage[] = [{ id: 'a2', role: 'assistant', segments: [second] }];
+    expect(joinPages(earlier, later)).toEqual([
+      earlier[0],
+      {
+        id: 'a2',
+        role: 'assistant',
+        segments: [first, second]
+      }
+    ]);
+    expect(answerText(earlier[1] as AssistantAnswerMessage)).toBe('Looking.');
+    expect(answerText(later[0] as AssistantAnswerMessage)).toBe('Done.');
+    expect(answerText(joinPages(earlier, later)[1] as AssistantAnswerMessage)).toBe('Looking.\n\nDone.');
+    expect(answerToolCalls(joinPages(earlier, later)[1] as AssistantAnswerMessage)).toEqual([
+      { id: 'c1', name: 'read' },
+      { id: 'c2', name: 'summarize' }
+    ]);
+  });
+
+  it('retains a tool-only reply across an empty intermediate page and deduplicates repeated calls', () => {
+    const call = { id: 'c1', name: 'read' };
+    const first = { id: 'a1', content: '', toolCalls: [call] };
+    const second = { id: 'a2', content: 'Done.', toolCalls: [call] };
+    const earlier: AssistantMessage[] = [{ id: 'a1', role: 'assistant', segments: [first] }];
+    const later: AssistantMessage[] = [{ id: 'a2', role: 'assistant', segments: [second] }];
+    expect(joinPages(joinPages(earlier, []), later)).toEqual([
+      { id: 'a2', role: 'assistant', segments: [first, second] }
+    ]);
+    expect(answerToolCalls(joinPages(joinPages(earlier, []), later)[0] as AssistantAnswerMessage)).toEqual([call]);
+  });
+
+  it('keeps interleaved text and tool rounds in server order across an overlapping page boundary', () => {
+    const rounds = [
+      { id: 'a1', content: 'First thought.', toolCalls: [{ id: 'c1', name: 'read' }] },
+      { id: 'a2', content: 'Second thought.', toolCalls: [{ id: 'c2', name: 'summarize' }] },
+      { id: 'a3', content: 'Final answer.' }
+    ];
+    const earlier: AssistantMessage[] = [{ id: 'a2', role: 'assistant', segments: rounds.slice(0, 2) }];
+    const later: AssistantMessage[] = [{ id: 'a3', role: 'assistant', segments: rounds.slice(1) }];
+    expect(joinPages(earlier, later)).toEqual([
+      {
+        id: 'a3',
+        role: 'assistant',
+        segments: rounds
+      }
+    ]);
+    const answer = joinPages(earlier, later)[0] as AssistantAnswerMessage;
+    expect(answerText(answer)).toBe('First thought.\n\nSecond thought.\n\nFinal answer.');
+    expect(answerToolCalls(answer)).toEqual([rounds[0].toolCalls![0], rounds[1].toolCalls![0]]);
+  });
+
+  it('keeps separate turns apart and handles empty history', () => {
+    const earlier: AssistantMessage[] = [
+      { id: 'a1', role: 'assistant', segments: [{ id: 'a1', content: 'First answer' }] }
+    ];
+    const later: AssistantMessage[] = [
+      { id: 'u2', role: 'user', content: 'Another question' },
+      { id: 'a2', role: 'assistant', segments: [{ id: 'a2', content: 'Another answer' }] }
+    ];
+    expect(joinPages(earlier, later)).toEqual([...earlier, ...later]);
+    expect(joinPages([], later)).toEqual(later);
+    expect(joinPages(earlier, [])).toEqual(earlier);
+    expect(joinPages([], [])).toEqual([]);
+  });
+
+  it('keeps a question apart from its answer on the next page', () => {
+    const earlier: AssistantMessage[] = [{ id: 'u1', role: 'user', content: 'Question' }];
+    const later: AssistantMessage[] = [{ id: 'a1', role: 'assistant', segments: [{ id: 'a1', content: 'Answer' }] }];
+    expect(joinPages(earlier, later)).toEqual([...earlier, ...later]);
+  });
+});
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/messageHistory.ts b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/messageHistory.ts
new file mode 100644
index 0000000000..aca53ce0bb
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/model/messageHistory.ts
@@ -0,0 +1,51 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import type { AssistantAnswerMessage, AssistantMessage, AssistantToolCallRef } from './assistantContract';
+
+export const answerText = (answer: AssistantAnswerMessage): string =>
+  answer.segments
+    .map(segment => segment.content)
+    .filter(Boolean)
+    .join('\n\n');
+
+export const answerToolCalls = (answer: AssistantAnswerMessage): AssistantToolCallRef[] =>
+  Array.from(
+    new Map(answer.segments.flatMap(segment => segment.toolCalls ?? []).map(call => [call.id, call])).values()
+  );
+
+export const mergeAnswers = (
+  earlier: AssistantAnswerMessage,
+  later: AssistantAnswerMessage
+): AssistantAnswerMessage => {
+  const segments = Array.from(
+    new Map([...earlier.segments, ...later.segments].map(segment => [segment.id, segment])).values()
+  );
+  return {
+    id: later.id,
+    role: 'assistant',
+    segments
+  };
+};
+
+/**
+ * Puts an earlier page in front of the loaded history. A turn split across the page boundary
+ * becomes one bubble again, under the later id.
+ */
+export const joinPages = (earlier: AssistantMessage[], later: AssistantMessage[]): AssistantMessage[] => {
+  const last = earlier[earlier.length - 1];
+  const first = later[0];
+  if (last?.role === 'assistant' && first?.role === 'assistant') {
+    return [...earlier.slice(0, -1), mergeAnswers(last, first), ...later.slice(1)];
+  }
+  return [...earlier, ...later];
+};
diff --git a/zeppelin-web-angular/projects/zeppelin-sdk/src/public-api.ts b/zeppelin-web-angular/projects/zeppelin-sdk/src/assistant-ui.ts
similarity index 52%
copy from zeppelin-web-angular/projects/zeppelin-sdk/src/public-api.ts
copy to zeppelin-web-angular/projects/zeppelin-sdk/src/assistant-ui.ts
index ba14a13c7e..913ebc6239 100644
--- a/zeppelin-web-angular/projects/zeppelin-sdk/src/public-api.ts
+++ b/zeppelin-web-angular/projects/zeppelin-sdk/src/assistant-ui.ts
@@ -10,7 +10,17 @@
  * limitations under the License.
  */
 
-export * from './angular-object';
-export * from './interfaces/public-api';
-export * from './message';
-export * from './paragraph-state';
+/** The notebook sidebar's width: its default and its resize range. The assistant panel shares it. */
+export const NOTEBOOK_SIDEBAR_WIDTH = { initial: 370, min: 280, max: 800 } as const;
+
+/** A notebook paragraph as the remote sees it: enough to label and link it. */
+export interface AssistantParagraphRef {
+  id: string;
+  title?: string;
+}
+
+/** Notebook DOM targets for the React navigation button and panel. */
+export interface AssistantSlot {
+  element: HTMLElement;
+  kind: 'navigation' | 'panel';
+}
diff --git a/zeppelin-web-angular/projects/zeppelin-sdk/src/public-api.ts b/zeppelin-web-angular/projects/zeppelin-sdk/src/public-api.ts
index ba14a13c7e..e7cab6800b 100644
--- a/zeppelin-web-angular/projects/zeppelin-sdk/src/public-api.ts
+++ b/zeppelin-web-angular/projects/zeppelin-sdk/src/public-api.ts
@@ -11,6 +11,7 @@
  */
 
 export * from './angular-object';
+export * from './assistant-ui';
 export * from './interfaces/public-api';
 export * from './message';
 export * from './paragraph-state';