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 00000000000..e2792bdf48f --- /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; + createConversation(body: { title?: string }): Promise; + deleteConversation(conversationId: string): Promise; + /** The latest page of history, or the page before `before` (a page cursor). */ + getMessages(conversationId: string, before?: string): Promise; + /** 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; +} + +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 00000000000..d378f5003ac --- /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 00000000000..aca53ce0bb0 --- /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/assistant-ui.ts b/zeppelin-web-angular/projects/zeppelin-sdk/src/assistant-ui.ts new file mode 100644 index 00000000000..913ebc62398 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-sdk/src/assistant-ui.ts @@ -0,0 +1,26 @@ +/* + * 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. + */ + +/** 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 ba14a13c7ec..e7cab6800b9 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';