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';