This is an automated email from the ASF dual-hosted git repository. voidmatcha pushed a commit to branch branch-voidmatcha-assistant-integration-temp in repository https://gitbox.apache.org/repos/asf/zeppelin.git
commit 4fcdd3ddbae5e9c4b20faab82f1b295acf28d930 Author: kimyenac <[email protected]> AuthorDate: Mon Oct 12 12:53:29 2026 +0900 [ZEPPELIN-6745] Add AI Assistant message item and answer view types --- .../assistant/ui/AssistantMessageItem.css.ts | 85 ++++++++++++++++++++++ .../assistant/ui/AssistantMessageItem.spec.tsx | 35 +++++++++ .../entities/assistant/ui/AssistantMessageItem.tsx | 67 +++++++++++++++++ .../src/entities/assistant/ui/answerView.ts | 44 +++++++++++ 4 files changed, 231 insertions(+) diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.css.ts b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.css.ts new file mode 100644 index 0000000000..3998cf6a3b --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.css.ts @@ -0,0 +1,85 @@ +/* + * 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 { keyframes, style } from '@vanilla-extract/css'; +import { enter, vars } from '@/shared/ui/assistant-theme/AssistantScope.css'; + +export const message = style({ + display: 'flex', + flexDirection: 'column', + gap: vars.space1, + minWidth: 0, + animation: `${enter} ${vars.durationModerate} ${vars.easeEntrance}` +}); + +export const user = style({ + alignSelf: 'flex-end', + alignItems: 'flex-end', + maxWidth: '85%' +}); + +export const assistant = style({ + alignSelf: 'stretch' +}); + +export const label = style({ + display: 'flex', + alignItems: 'center', + gap: vars.space2, + color: vars.textSecondary, + fontSize: vars.fontSizeSm +}); + +export const badge = style({ + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + width: 20, + height: 20, + borderRadius: vars.radiusSm, + background: vars.accent, + color: vars.bg, + fontSize: vars.fontSizeXs, + fontWeight: 600 +}); + +export const bubble = style({ + padding: `${vars.space2} ${vars.space3}`, + borderRadius: vars.radiusLg, + border: `1px solid ${vars.accentBorder}`, + background: vars.accentBg, + overflowWrap: 'anywhere' +}); + +export const body = style({ + minWidth: 0, + overflowWrap: 'anywhere' +}); + +export const plainText = style({ + whiteSpace: 'pre-wrap' +}); + +const blink = keyframes({ + '50%': { opacity: 0 } +}); + +/** Trails the partial answer while it streams. */ +export const caret = style({ + display: 'inline-block', + width: 2, + height: '1em', + marginLeft: 2, + verticalAlign: 'text-bottom', + background: vars.accent, + animation: `${blink} 1s steps(1) infinite` +}); diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.spec.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.spec.tsx new file mode 100644 index 0000000000..0b280b4277 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.spec.tsx @@ -0,0 +1,35 @@ +/* + * 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 { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { AssistantMessageItem } from './AssistantMessageItem'; + +describe('AssistantMessageItem', () => { + it('tells a question from an answer', () => { + render( + <> + <AssistantMessageItem role="user" content="How is revenue calculated?" /> + <AssistantMessageItem role="assistant" content="By region." /> + </> + ); + expect(screen.getByRole('article', { name: 'Your question' }).textContent).toBe('YouHow is revenue calculated?'); + expect(screen.getByRole('article', { name: 'Assistant answer' }).textContent).toContain('By region.'); + }); + + it('shows a question as typed instead of rendering it', () => { + render(<AssistantMessageItem role="user" content="<b>not bold</b>" renderContent={() => 'rendered'} />); + const question = screen.getByRole('article', { name: 'Your question' }); + expect(question.textContent).toContain('<b>not bold</b>'); + expect(question.querySelector('b')).toBeNull(); + }); +}); diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.tsx new file mode 100644 index 0000000000..e7c1e4913e --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.tsx @@ -0,0 +1,67 @@ +/* + * 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 { ReactNode } from 'react'; +import type { AssistantMessage } from '../model/assistantContract'; +import type { AssistantContentRenderer } from './answerView'; +import * as styles from './AssistantMessageItem.css'; + +const renderPlainText: AssistantContentRenderer = content => <div className={styles.plainText}>{content}</div>; + +export interface AssistantMessageItemProps { + role: AssistantMessage['role']; + content: string; + /** The answer is still arriving: marks it busy and shows a caret after the text. */ + streaming?: boolean; + renderContent?: AssistantContentRenderer; + /** Shown under an assistant answer, such as the tools it used or an error. */ + children?: ReactNode; +} + +export const AssistantMessageItem = ({ + role, + content, + streaming = false, + renderContent = renderPlainText, + children +}: AssistantMessageItemProps) => { + if (role === 'user') { + return ( + <article className={`${styles.message} ${styles.user}`} data-role="user" aria-label="Your question"> + <div className={styles.label}>You</div> + <div className={`${styles.bubble} ${styles.plainText}`}>{content}</div> + </article> + ); + } + return ( + <article + className={`${styles.message} ${styles.assistant}`} + data-role="assistant" + aria-label="Assistant answer" + aria-busy={streaming || undefined} + > + <header className={styles.label}> + <span className={styles.badge} aria-hidden="true"> + AI + </span> + <span>Assistant</span> + </header> + {content && ( + <div className={styles.body}> + {renderContent(content)} + {streaming && <span className={styles.caret} aria-hidden="true" />} + </div> + )} + {children} + </article> + ); +}; diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/answerView.ts b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/answerView.ts new file mode 100644 index 0000000000..b11e7ebcfc --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/answerView.ts @@ -0,0 +1,44 @@ +/* + * 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 { ReactNode } from 'react'; +import type { AssistantMessage } from '../model/assistantContract'; + +/** A tool the assistant called during a live run. `tool_call.done` may omit the name, so it is optional. */ +export interface AssistantToolActivity { + id: string; + name?: string; + status: 'running' | 'done'; +} + +/** + * Why an answer stopped: the server reported `run.failed`, the stream ended early (`AssistantStreamError`), + * or the connection dropped and only a reload shows where the answer got to (`AssistantConnectionError`). + */ +export type AssistantAnswerErrorKind = 'failed' | 'stream' | 'connection'; + +/** What one answer shows. The panel derives it from run events; the components only display it. */ +export type AssistantAnswerView = + | { status: 'loading' } + | { status: 'streaming'; content: string; tools?: AssistantToolActivity[] } + | { status: 'done'; message: AssistantMessage } + | { + status: 'error'; + kind: AssistantAnswerErrorKind; + message: string; + /** The part of the answer that arrived before it stopped. */ + content?: string; + tools?: AssistantToolActivity[]; + }; + +/** Renders an answer body. Plain text until the Markdown answer component is wired in. */ +export type AssistantContentRenderer = (content: string) => ReactNode;
