From c5f595a8ba5c5b9b326e899850b5f4cccf5831f7 Mon Sep 17 00:00:00 2001 From: kimyenac Date: Mon, 12 Oct 2026 12:53:29 +0900 Subject: [PATCH 1/6] [ZEPPELIN-6745] Add AI Assistant message item and answer view types --- .../assistant/ui/AssistantMessageItem.css.ts | 85 +++++++++++++++++++ .../ui/AssistantMessageItem.spec.tsx | 35 ++++++++ .../assistant/ui/AssistantMessageItem.tsx | 67 +++++++++++++++ .../src/entities/assistant/ui/answerView.ts | 44 ++++++++++ 4 files changed, 231 insertions(+) create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.css.ts create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.spec.tsx create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.tsx create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/answerView.ts 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 00000000000..3998cf6a3b4 --- /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 00000000000..0b280b42779 --- /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( + <> + + + + ); + 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( 'rendered'} />); + const question = screen.getByRole('article', { name: 'Your question' }); + expect(question.textContent).toContain('not bold'); + 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 00000000000..e7c1e4913e9 --- /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 =>
{content}
; + +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 ( +
+
You
+
{content}
+
+ ); + } + return ( +
+
+ + Assistant +
+ {content && ( +
+ {renderContent(content)} + {streaming &&
+ )} + {children} +
+ ); +}; 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 00000000000..b11e7ebcfc3 --- /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; From 5d244dd6816b2615404e6af3d84ff56b8114b4dc Mon Sep 17 00:00:00 2001 From: kimyenac Date: Mon, 12 Oct 2026 12:53:40 +0900 Subject: [PATCH 2/6] [ZEPPELIN-6745] Add AI Assistant answer status, tool log, and error displays --- .../assistant/ui/AssistantAnswer.css.ts | 91 ++++++++++++ .../assistant/ui/AssistantAnswer.spec.tsx | 137 ++++++++++++++++++ .../entities/assistant/ui/AssistantAnswer.tsx | 79 ++++++++++ .../ui/AssistantAnswerError.spec.tsx | 49 +++++++ .../assistant/ui/AssistantAnswerError.tsx | 46 ++++++ .../assistant/ui/AssistantToolLog.tsx | 48 ++++++ 6 files changed, 450 insertions(+) create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.css.ts create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.spec.tsx create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.tsx create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswerError.spec.tsx create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswerError.tsx create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantToolLog.tsx diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.css.ts b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.css.ts new file mode 100644 index 00000000000..044307eb430 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.css.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. + */ + +import { globalStyle, keyframes, style } from '@vanilla-extract/css'; +import { vars } from '@/shared/ui/assistant-theme/AssistantScope.css'; + +export const progress = style({ + display: 'flex', + alignItems: 'center', + gap: vars.space2, + color: vars.textSecondary, + fontSize: vars.fontSizeSm +}); + +const pulse = keyframes({ + '0%, 80%, 100%': { opacity: 0.25 }, + '40%': { opacity: 1 } +}); + +export const dots = style({ + display: 'inline-flex', + gap: 3 +}); + +globalStyle(`${dots} > i`, { + width: 4, + height: 4, + borderRadius: '50%', + background: vars.accent, + animation: `${pulse} 1.2s ${vars.easeStandard} infinite` +}); +globalStyle(`${dots} > i:nth-child(2)`, { animationDelay: '0.15s' }); +globalStyle(`${dots} > i:nth-child(3)`, { animationDelay: '0.3s' }); + +export const skeleton = style({ + marginTop: vars.space1 +}); + +export const toolLog = style({ + border: `1px solid ${vars.border}`, + borderRadius: vars.radius, + background: vars.fill, + fontSize: vars.fontSizeSm +}); + +export const toolSummary = style({ + padding: `${vars.space1} ${vars.space2}`, + color: vars.textSecondary, + cursor: 'pointer' +}); + +export const toolList = style({ + listStyle: 'none', + margin: 0, + padding: `0 ${vars.space2} ${vars.space2}`, + display: 'flex', + flexDirection: 'column', + gap: vars.space1 +}); + +export const toolItem = style({ + display: 'flex', + alignItems: 'center', + gap: vars.space2, + color: vars.text +}); + +export const toolIcon = style({ + display: 'inline-flex', + width: 14, + justifyContent: 'center', + color: vars.textSecondary +}); + +export const toolDone = style({ + color: vars.success +}); + +export const toolName = style({ + fontFamily: vars.fontMono, + overflowWrap: 'anywhere' +}); diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.spec.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.spec.tsx new file mode 100644 index 00000000000..7531cf429ef --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.spec.tsx @@ -0,0 +1,137 @@ +/* + * 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, within } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import type { AssistantAnswerView } from './answerView'; +import { AssistantAnswer } from './AssistantAnswer'; + +const answer = () => screen.getByRole('article', { name: 'Assistant answer' }); + +describe('AssistantAnswer', () => { + it('shows only the current state as the run moves from loading to done', () => { + const { rerender } = render(); + expect(screen.getByRole('status').textContent).toBe('Thinking…'); + expect(answer().getAttribute('aria-busy')).toBe('true'); + + rerender( + + ); + expect(screen.getByRole('status').textContent).toBe('Writing answer…'); + expect(answer().textContent).toContain('Revenue is grouped'); + + rerender( + + ); + expect(screen.queryByRole('status')).toBeNull(); + expect(screen.queryByRole('alert')).toBeNull(); + expect(answer().hasAttribute('aria-busy')).toBe(false); + expect(answer().textContent).toContain('Revenue is grouped by region.'); + expect(screen.getByText('Used 1 tool')).toBeTruthy(); + }); + + it('names the running tool in the progress line and marks each tool with its state', () => { + render( + + ); + expect(screen.getByRole('status').textContent).toBe('Running read_results…'); + expect(screen.getByText('Using 2 tools')).toBeTruthy(); + const [first, second] = screen.getAllByRole('listitem'); + expect(within(first).getByRole('img', { name: 'Done' })).toBeTruthy(); + expect(within(second).getByRole('img', { name: 'Running' })).toBeTruthy(); + }); + + it('opens the tool log while a tool runs and collapses it when the answer completes', () => { + const running: AssistantAnswerView = { + status: 'streaming', + content: '', + tools: [{ id: 't1', name: 'list_paragraphs', status: 'running' }] + }; + const toolLog = () => screen.getByText(/tool$/).closest('details')!; + const { rerender } = render(); + expect(toolLog().open).toBe(true); + + // A user who collapses the log keeps it collapsed while more of the answer arrives. + toolLog().open = false; + rerender(); + expect(toolLog().open).toBe(false); + + rerender( + + ); + expect(toolLog().open).toBe(false); + }); + + it('labels a tool whose completion event carried no name', () => { + render(); + expect(screen.getByRole('status').textContent).toBe('Running Tool…'); + }); + + it('keeps the partial answer and replaces the progress line with the error', () => { + const view: AssistantAnswerView = { + status: 'error', + kind: 'stream', + message: 'The assistant stopped responding before the reply finished. Retry the request.', + content: 'Revenue is grouped' + }; + render(); + expect(screen.queryByRole('status')).toBeNull(); + expect(answer().hasAttribute('aria-busy')).toBe(false); + expect(answer().textContent).toContain('Revenue is grouped'); + expect(screen.getByRole('alert').textContent).toContain('stopped responding'); + }); + + it('renders answer bodies through the given renderer', () => { + render( + {content.replaceAll('*', '')}} + /> + ); + expect(answer().querySelector('strong')?.textContent).toBe('bold'); + }); +}); diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.tsx new file mode 100644 index 00000000000..b0f6fbecbcb --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.tsx @@ -0,0 +1,79 @@ +/* + * 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 { Skeleton } from 'antd'; +import type { AssistantContentRenderer, AssistantAnswerView, AssistantToolActivity } from './answerView'; +import { AssistantAnswerError } from './AssistantAnswerError'; +import { AssistantMessageItem } from './AssistantMessageItem'; +import { AssistantToolLog, toolLabel } from './AssistantToolLog'; +import * as styles from './AssistantAnswer.css'; + +const Progress = ({ text }: { text: string }) => ( +
+ + {text} +
+); + +const progressText = (content: string, tools: AssistantToolActivity[]) => { + const running = tools.find(tool => tool.status === 'running'); + if (running) return `Running ${toolLabel(running)}…`; + return content ? 'Writing answer…' : 'Thinking…'; +}; + +export interface AssistantAnswerProps { + view: AssistantAnswerView; + /** Called from the error display. */ + onRetry?: () => void; + renderContent?: AssistantContentRenderer; +} + +/** One assistant answer in whichever state its run is in. Each state replaces the last, so only one shows at a time. */ +export const AssistantAnswer = ({ view, onRetry, renderContent }: AssistantAnswerProps) => { + switch (view.status) { + case 'loading': + return ( + + + + + ); + case 'streaming': { + const tools = view.tools ?? []; + return ( + + + + + ); + } + case 'done': { + const tools = (view.message.toolCalls ?? []).map(call => ({ ...call, status: 'done' as const })); + return ( + + + + ); + } + case 'error': + return ( + + + + + ); + } +}; diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswerError.spec.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswerError.spec.tsx new file mode 100644 index 00000000000..644505c632c --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswerError.spec.tsx @@ -0,0 +1,49 @@ +/* + * 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 { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { AssistantConnectionError, AssistantStreamError } from '../model/assistantContract'; +import { AssistantAnswerError } from './AssistantAnswerError'; + +describe('AssistantAnswerError', () => { + it('shows a server failure with a retry action', () => { + const onRetry = vi.fn(); + render(); + expect(screen.getByRole('alert').textContent).toContain('The model request failed.'); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + expect(onRetry).toHaveBeenCalledOnce(); + }); + + it('offers retry when the stream ended early', () => { + render( {}} />); + expect(screen.getByRole('alert').textContent).toContain('stopped responding before the reply finished'); + expect(screen.getByRole('button', { name: 'Retry' })).toBeTruthy(); + }); + + it('asks for a reload, not a retry, when the connection dropped', () => { + const onRetry = vi.fn(); + render( + + ); + expect(screen.getByRole('alert').textContent).toContain('connection to the server was lost'); + expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'Reload conversation' })); + expect(onRetry).toHaveBeenCalledOnce(); + }); + + it('shows no button when there is nothing to retry', () => { + render(); + expect(screen.getByRole('alert')).toBeTruthy(); + expect(screen.queryByRole('button')).toBeNull(); + }); +}); diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswerError.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswerError.tsx new file mode 100644 index 00000000000..ebfb6e80fa5 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswerError.tsx @@ -0,0 +1,46 @@ +/* + * 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 { Alert, Button } from 'antd'; +import type { AssistantAnswerErrorKind } from './answerView'; + +const presentation: Record = { + failed: { type: 'error', action: 'Retry' }, + stream: { type: 'error', action: 'Retry' }, + // The answer may have finished on the server; only reloading the conversation shows where it got to. + connection: { type: 'warning', action: 'Reload conversation' } +}; + +export interface AssistantAnswerErrorProps { + kind: AssistantAnswerErrorKind; + message: string; + /** Without it there is nothing to offer, so no button is shown. */ + onRetry?: () => void; +} + +export const AssistantAnswerError = ({ kind, message, onRetry }: AssistantAnswerErrorProps) => { + const { type, action } = presentation[kind]; + return ( + + {action} + + ) + } + /> + ); +}; diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantToolLog.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantToolLog.tsx new file mode 100644 index 00000000000..46a0a111a68 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantToolLog.tsx @@ -0,0 +1,48 @@ +/* + * 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 { CheckOutlined } from '@ant-design/icons'; +import { Spin } from 'antd'; +import type { AssistantToolActivity } from './answerView'; +import * as styles from './AssistantAnswer.css'; + +export const toolLabel = (tool: Pick) => tool.name || 'Tool'; + +export interface AssistantToolLogProps { + tools: AssistantToolActivity[]; +} + +/** The tools behind an answer. Open while one is still running, collapsed once they have all finished. */ +export const AssistantToolLog = ({ tools }: AssistantToolLogProps) => { + if (!tools.length) return null; + const running = tools.some(tool => tool.status === 'running'); + const count = `${tools.length} ${tools.length === 1 ? 'tool' : 'tools'}`; + return ( +
+ {running ? `Using ${count}` : `Used ${count}`} +
    + {tools.map(tool => ( +
  • + {tool.status === 'running' ? ( + + + + ) : ( + + )} + {toolLabel(tool)} +
  • + ))} +
+
+ ); +}; From 08340858f7c5a107b0704b66a2a13a02b602092c Mon Sep 17 00:00:00 2001 From: kimyenac Date: Mon, 12 Oct 2026 12:53:50 +0900 Subject: [PATCH 3/6] [ZEPPELIN-6745] Add stories for AI Assistant messages and answer states --- .../assistant/ui/AssistantAnswer.stories.tsx | 88 +++++++++++++++++++ .../ui/AssistantMessageItem.stories.tsx | 62 +++++++++++++ 2 files changed, 150 insertions(+) create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.stories.tsx create mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.stories.tsx diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.stories.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.stories.tsx new file mode 100644 index 00000000000..cedeff5d6e6 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.stories.tsx @@ -0,0 +1,88 @@ +/* + * 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 { Meta, StoryObj } from '@storybook/react-vite'; +import { AssistantConnectionError, AssistantStreamError } from '../model/assistantContract'; +import type { AssistantAnswerView } from './answerView'; +import { AssistantAnswer } from './AssistantAnswer'; + +const partial = 'The notebook groups orders by region and'; +const reply = 'The notebook groups orders by region and ranks them by total revenue.'; + +/** One fixture per state, in the order a run passes through them. */ +const fixtures = { + loading: { status: 'loading' }, + streaming: { status: 'streaming', content: partial }, + toolRunning: { + status: 'streaming', + content: '', + tools: [ + { id: 't1', name: 'list_paragraphs', status: 'done' }, + { id: 't2', name: 'list_paragraphs', status: 'running' } + ] + }, + completed: { status: 'done', message: { id: 'a1', role: 'assistant', content: reply } }, + completedWithTools: { + status: 'done', + message: { + id: 'a1', + role: 'assistant', + content: reply, + toolCalls: [ + { id: 't1', name: 'list_paragraphs' }, + { id: 't2', name: 'list_paragraphs' } + ] + } + }, + failed: { status: 'error', kind: 'failed', message: 'The model request failed.' }, + streamError: { + status: 'error', + kind: 'stream', + message: new AssistantStreamError().message, + content: partial, + tools: [{ id: 't1', name: 'list_paragraphs', status: 'done' }] + }, + connectionLost: { + status: 'error', + kind: 'connection', + message: new AssistantConnectionError().message, + content: partial + } +} satisfies Record; + +type FixtureName = keyof typeof fixtures; + +const meta = { + title: 'Assistant/Status', + component: AssistantAnswer, + args: { view: fixtures.loading, onRetry: () => {} } +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Loading: Story = {}; +export const Streaming: Story = { args: { view: fixtures.streaming } }; +export const ToolRunning: Story = { args: { view: fixtures.toolRunning } }; +export const Completed: Story = { args: { view: fixtures.completed } }; +export const CompletedWithTools: Story = { args: { view: fixtures.completedWithTools } }; +export const Failed: Story = { args: { view: fixtures.failed } }; +export const StreamError: Story = { args: { view: fixtures.streamError } }; +export const ConnectionLost: Story = { args: { view: fixtures.connectionLost } }; +export const ErrorWithoutRetry: Story = { args: { view: fixtures.failed, onRetry: undefined } }; + +/** Switch the fixture from the controls panel to check that each state fully replaces the last. */ +export const StateSwitcher: StoryObj<{ state: FixtureName }> = { + args: { state: 'loading' }, + argTypes: { state: { control: 'select', options: Object.keys(fixtures) } }, + render: ({ state }) => {}} /> +}; diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.stories.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.stories.tsx new file mode 100644 index 00000000000..75931eb2b71 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.stories.tsx @@ -0,0 +1,62 @@ +/* + * 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 { Meta, StoryObj } from '@storybook/react-vite'; +import { AssistantAnswer } from './AssistantAnswer'; +import { AssistantMessageItem } from './AssistantMessageItem'; + +const question = 'Explain how this notebook calculates revenue.'; +const reply = 'The notebook groups orders by region and ranks them by total revenue.'; + +const meta = { + title: 'Assistant/Message', + component: AssistantMessageItem, + args: { role: 'user', content: question } +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const User: Story = {}; + +export const CompletedReply: Story = { + args: { role: 'assistant', content: reply } +}; + +export const StreamingReply: Story = { + args: { role: 'assistant', content: 'The notebook groups orders by region and', streaming: true } +}; + +export const LongContent: Story = { + args: { + role: 'assistant', + content: [ + 'Paragraph 2 loads `orders` and joins it with `regions` on region_id.', + 'Paragraph 3 sums amount per region and sorts the totals in descending order.', + 'A very long identifier such as orders_by_region_with_currency_normalisation_applied_v2 wraps instead of overflowing.' + ].join('\n\n') + } +}; + +export const Conversation: Story = { + render: () => ( +
+ + +
+ ) +}; From 93b266a8f34a77cbfe827cbb4ecbd63970e52df4 Mon Sep 17 00:00:00 2001 From: kimyenac Date: Mon, 12 Oct 2026 21:01:55 +0900 Subject: [PATCH 4/6] [ZEPPELIN-6745] Remove the temporary Assistant theme check story --- .../AssistantScope.stories.tsx | 35 ------------------- 1 file changed, 35 deletions(-) delete mode 100644 zeppelin-web-angular/projects/zeppelin-react/src/shared/ui/assistant-theme/AssistantScope.stories.tsx diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/shared/ui/assistant-theme/AssistantScope.stories.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/shared/ui/assistant-theme/AssistantScope.stories.tsx deleted file mode 100644 index c39bb9d5a17..00000000000 --- a/zeppelin-web-angular/projects/zeppelin-react/src/shared/ui/assistant-theme/AssistantScope.stories.tsx +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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 { Button, Space, Typography } from 'antd'; -import type { Meta, StoryObj } from '@storybook/react-vite'; - -const ThemeSurface = () => ( - - Assistant theme - - - Secondary text - -); - -const meta = { - title: 'Assistant/Setup', - component: ThemeSurface, - parameters: { - docs: { description: { component: 'Temporary theme check. Remove after the Assistant UI stories land.' } } - } -} satisfies Meta; - -export default meta; -type Story = StoryObj; -export const TemporaryThemeCheck: Story = {}; From 00c11786f49febd8ec56613ea0b6a474c34df47a Mon Sep 17 00:00:00 2001 From: YONGJAE LEE Date: Fri, 16 Oct 2026 12:42:19 +0900 Subject: [PATCH 5/6] [ZEPPELIN-6745] Align answer UI with segmented history --- .../assistant/ui/AssistantAnswer.spec.tsx | 53 +++++++++++++++++-- .../assistant/ui/AssistantAnswer.stories.tsx | 19 ++++--- .../entities/assistant/ui/AssistantAnswer.tsx | 13 +++-- .../ui/AssistantMessageItem.stories.tsx | 6 ++- .../assistant/ui/AssistantToolLog.tsx | 9 +++- .../src/entities/assistant/ui/answerView.ts | 6 +-- 6 files changed, 85 insertions(+), 21 deletions(-) diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.spec.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.spec.tsx index 7531cf429ef..e1a3ed4253d 100644 --- a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.spec.tsx +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.spec.tsx @@ -42,8 +42,10 @@ describe('AssistantAnswer', () => { message: { id: 'm1', role: 'assistant', - content: 'Revenue is grouped by region.', - toolCalls: [{ id: 't1', name: 'list_paragraphs' }] + segments: [ + { id: 's1', content: '', toolCalls: [{ id: 't1', name: 'list_paragraphs' }] }, + { id: 's2', content: 'Revenue is grouped by region.' } + ] } }} /> @@ -97,8 +99,7 @@ describe('AssistantAnswer', () => { message: { id: 'm1', role: 'assistant', - content: 'Revenue', - toolCalls: [{ id: 't1', name: 'list_paragraphs' }] + segments: [{ id: 's1', content: 'Revenue', toolCalls: [{ id: 't1', name: 'list_paragraphs' }] }] } }} /> @@ -111,6 +112,28 @@ describe('AssistantAnswer', () => { expect(screen.getByRole('status').textContent).toBe('Running Tool…'); }); + it('keeps completed answer rounds in order and shows tools from every round', () => { + render( + + ); + + expect(answer().textContent).toContain('First finding\n\nSecond finding'); + expect(screen.getByText('Used 2 tools')).toBeTruthy(); + expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['list_paragraphs', 'read_results']); + }); + it('keeps the partial answer and replaces the progress line with the error', () => { const view: AssistantAnswerView = { status: 'error', @@ -125,10 +148,30 @@ describe('AssistantAnswer', () => { expect(screen.getByRole('alert').textContent).toContain('stopped responding'); }); + it('does not imply a tool is still observed after the connection is lost', () => { + render( + + ); + + expect(screen.getByText('Used 1 tool (status unknown)')).toBeTruthy(); + expect(screen.queryByRole('img', { name: 'Running' })).toBeNull(); + expect(screen.getByRole('img', { name: 'Status unknown' })).toBeTruthy(); + }); + it('renders answer bodies through the given renderer', () => { render( {content.replaceAll('*', '')}} /> ); diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.stories.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.stories.tsx index cedeff5d6e6..dc472e26962 100644 --- a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.stories.tsx +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.stories.tsx @@ -30,16 +30,22 @@ const fixtures = { { id: 't2', name: 'list_paragraphs', status: 'running' } ] }, - completed: { status: 'done', message: { id: 'a1', role: 'assistant', content: reply } }, + completed: { status: 'done', message: { id: 'a1', role: 'assistant', segments: [{ id: 's1', content: reply }] } }, completedWithTools: { status: 'done', message: { id: 'a1', role: 'assistant', - content: reply, - toolCalls: [ - { id: 't1', name: 'list_paragraphs' }, - { id: 't2', name: 'list_paragraphs' } + segments: [ + { + id: 's1', + content: '', + toolCalls: [ + { id: 't1', name: 'list_paragraphs' }, + { id: 't2', name: 'list_paragraphs' } + ] + }, + { id: 's2', content: reply } ] } }, @@ -55,7 +61,8 @@ const fixtures = { status: 'error', kind: 'connection', message: new AssistantConnectionError().message, - content: partial + content: partial, + tools: [{ id: 't1', name: 'read_results', status: 'running' }] } } satisfies Record; diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.tsx index b0f6fbecbcb..1139c73b2eb 100644 --- a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.tsx +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.tsx @@ -11,6 +11,7 @@ */ import { Skeleton } from 'antd'; +import { answerText, answerToolCalls } from '../model/messageHistory'; import type { AssistantContentRenderer, AssistantAnswerView, AssistantToolActivity } from './answerView'; import { AssistantAnswerError } from './AssistantAnswerError'; import { AssistantMessageItem } from './AssistantMessageItem'; @@ -61,19 +62,23 @@ export const AssistantAnswer = ({ view, onRetry, renderContent }: AssistantAnswe ); } case 'done': { - const tools = (view.message.toolCalls ?? []).map(call => ({ ...call, status: 'done' as const })); + const tools = answerToolCalls(view.message).map(call => ({ ...call, status: 'done' as const })); return ( - + ); } - case 'error': + case 'error': { + const tools = (view.tools ?? []).map(tool => + tool.status === 'running' ? { ...tool, status: 'unknown' as const } : tool + ); return ( - + ); + } } }; diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.stories.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.stories.tsx index 75931eb2b71..69ba1ccff49 100644 --- a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.stories.tsx +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.stories.tsx @@ -54,7 +54,11 @@ export const Conversation: Story = { diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantToolLog.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantToolLog.tsx index 46a0a111a68..aa1359733ab 100644 --- a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantToolLog.tsx +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantToolLog.tsx @@ -10,7 +10,7 @@ * limitations under the License. */ -import { CheckOutlined } from '@ant-design/icons'; +import { CheckOutlined, QuestionCircleOutlined } from '@ant-design/icons'; import { Spin } from 'antd'; import type { AssistantToolActivity } from './answerView'; import * as styles from './AssistantAnswer.css'; @@ -25,10 +25,13 @@ export interface AssistantToolLogProps { export const AssistantToolLog = ({ tools }: AssistantToolLogProps) => { if (!tools.length) return null; const running = tools.some(tool => tool.status === 'running'); + const unknown = tools.some(tool => tool.status === 'unknown'); const count = `${tools.length} ${tools.length === 1 ? 'tool' : 'tools'}`; return (
- {running ? `Using ${count}` : `Used ${count}`} + + {running ? `Using ${count}` : unknown ? `Used ${count} (status unknown)` : `Used ${count}`} +
    {tools.map(tool => (
  • @@ -36,6 +39,8 @@ export const AssistantToolLog = ({ tools }: AssistantToolLogProps) => { + ) : tool.status === 'unknown' ? ( + ) : ( )} 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 index b11e7ebcfc3..f154658c791 100644 --- 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 @@ -11,13 +11,13 @@ */ import type { ReactNode } from 'react'; -import type { AssistantMessage } from '../model/assistantContract'; +import type { AssistantAnswerMessage } 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'; + status: 'running' | 'done' | 'unknown'; } /** @@ -30,7 +30,7 @@ export type AssistantAnswerErrorKind = 'failed' | 'stream' | 'connection'; export type AssistantAnswerView = | { status: 'loading' } | { status: 'streaming'; content: string; tools?: AssistantToolActivity[] } - | { status: 'done'; message: AssistantMessage } + | { status: 'done'; message: AssistantAnswerMessage } | { status: 'error'; kind: AssistantAnswerErrorKind; From 06dcb82945e45df17d99a952becd2ad3ad2ab5ff Mon Sep 17 00:00:00 2001 From: kimyenac Date: Tue, 20 Oct 2026 13:42:49 +0900 Subject: [PATCH 6/6] [ZEPPELIN-6745] Render answers as Markdown by default --- .../assistant/ui/AssistantAnswer.spec.tsx | 2 +- .../assistant/ui/AssistantMessageItem.css.ts | 30 ++++++++++++------- .../ui/AssistantMessageItem.spec.tsx | 7 +++++ .../assistant/ui/AssistantMessageItem.tsx | 8 ++--- .../src/entities/assistant/ui/answerView.ts | 2 +- 5 files changed, 32 insertions(+), 17 deletions(-) diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.spec.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.spec.tsx index e1a3ed4253d..50edf035857 100644 --- a/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.spec.tsx +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.spec.tsx @@ -129,7 +129,7 @@ describe('AssistantAnswer', () => { /> ); - expect(answer().textContent).toContain('First finding\n\nSecond finding'); + expect(Array.from(answer().querySelectorAll('p'), p => p.textContent)).toEqual(['First finding', 'Second finding']); expect(screen.getByText('Used 2 tools')).toBeTruthy(); expect(screen.getAllByRole('listitem').map(item => item.textContent)).toEqual(['list_paragraphs', 'read_results']); }); 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 index 3998cf6a3b4..3ec15837120 100644 --- 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 @@ -10,7 +10,7 @@ * limitations under the License. */ -import { keyframes, style } from '@vanilla-extract/css'; +import { globalStyle, keyframes, style } from '@vanilla-extract/css'; import { enter, vars } from '@/shared/ui/assistant-theme/AssistantScope.css'; export const message = style({ @@ -73,13 +73,21 @@ 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` -}); +/** + * Trails the partial answer while it streams. The renderer wraps the answer in blocks, so the caret goes inside the + * last block (or the last item of a closing list) to stay on the line where the text ends. + */ +globalStyle( + `${body}[data-streaming] > :last-child > :last-child::after, ${body}[data-streaming] > :last-child > :is(ul, ol):last-child > li:last-child::after`, + { + content: '""', + display: 'inline-block', + width: 2, + height: '1em', + marginLeft: 2, + verticalAlign: 'text-bottom', + background: vars.accent, + animation: `${blink} 1s steps(1) infinite` + } +); +globalStyle(`${body}[data-streaming] > :last-child > :is(ul, ol):last-child::after`, { content: 'none' }); 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 index 0b280b42779..d436f221644 100644 --- 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 @@ -32,4 +32,11 @@ describe('AssistantMessageItem', () => { expect(question.textContent).toContain('not bold'); expect(question.querySelector('b')).toBeNull(); }); + + it('renders an answer as Markdown by default', () => { + render(); + const answer = screen.getByRole('article', { name: 'Assistant answer' }); + expect(answer.querySelector('strong')?.textContent).toBe('grouped'); + expect(answer.querySelector('code')?.textContent).toBe('region'); + }); }); 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 index e7c1e4913e9..fa6203db258 100644 --- 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 @@ -14,8 +14,9 @@ import type { ReactNode } from 'react'; import type { AssistantMessage } from '../model/assistantContract'; import type { AssistantContentRenderer } from './answerView'; import * as styles from './AssistantMessageItem.css'; +import { MarkdownAnswer } from './MarkdownAnswer'; -const renderPlainText: AssistantContentRenderer = content =>
    {content}
    ; +const renderMarkdown: AssistantContentRenderer = content => ; export interface AssistantMessageItemProps { role: AssistantMessage['role']; @@ -31,7 +32,7 @@ export const AssistantMessageItem = ({ role, content, streaming = false, - renderContent = renderPlainText, + renderContent = renderMarkdown, children }: AssistantMessageItemProps) => { if (role === 'user') { @@ -56,9 +57,8 @@ export const AssistantMessageItem = ({ Assistant {content && ( -
    +
    {renderContent(content)} - {streaming &&
    )} {children} 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 index f154658c791..164a8ed8d5a 100644 --- 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 @@ -40,5 +40,5 @@ export type AssistantAnswerView = tools?: AssistantToolActivity[]; }; -/** Renders an answer body. Plain text until the Markdown answer component is wired in. */ +/** Renders an answer body. Defaults to `MarkdownAnswer`; a parent can pass one that resolves paragraph references. */ export type AssistantContentRenderer = (content: string) => ReactNode;