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 dbe91174bdbb9a09c3d2430d6d5009f2485f1fad Author: kimyenac <[email protected]> AuthorDate: Mon Oct 12 12:53:40 2026 +0900 [ZEPPELIN-6745] Add AI Assistant answer status, tool log, and error displays --- .../entities/assistant/ui/AssistantAnswer.css.ts | 91 ++++++++++++++ .../entities/assistant/ui/AssistantAnswer.spec.tsx | 137 +++++++++++++++++++++ .../src/entities/assistant/ui/AssistantAnswer.tsx | 79 ++++++++++++ .../assistant/ui/AssistantAnswerError.spec.tsx | 49 ++++++++ .../entities/assistant/ui/AssistantAnswerError.tsx | 46 +++++++ .../src/entities/assistant/ui/AssistantToolLog.tsx | 48 ++++++++ 6 files changed, 450 insertions(+) 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 0000000000..044307eb43 --- /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 0000000000..7531cf429e --- /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(<AssistantAnswer view={{ status: 'loading' }} />); + expect(screen.getByRole('status').textContent).toBe('Thinking…'); + expect(answer().getAttribute('aria-busy')).toBe('true'); + + rerender( + <AssistantAnswer + view={{ + status: 'streaming', + content: 'Revenue is grouped', + tools: [{ id: 't1', name: 'list_paragraphs', status: 'done' }] + }} + /> + ); + expect(screen.getByRole('status').textContent).toBe('Writing answer…'); + expect(answer().textContent).toContain('Revenue is grouped'); + + rerender( + <AssistantAnswer + view={{ + status: 'done', + message: { + id: 'm1', + role: 'assistant', + content: 'Revenue is grouped by region.', + toolCalls: [{ id: 't1', name: 'list_paragraphs' }] + } + }} + /> + ); + 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( + <AssistantAnswer + view={{ + status: 'streaming', + content: '', + tools: [ + { id: 't1', name: 'list_paragraphs', status: 'done' }, + { id: 't2', name: 'read_results', status: 'running' } + ] + }} + /> + ); + 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(<AssistantAnswer view={running} />); + expect(toolLog().open).toBe(true); + + // A user who collapses the log keeps it collapsed while more of the answer arrives. + toolLog().open = false; + rerender(<AssistantAnswer view={{ ...running, content: 'Revenue' }} />); + expect(toolLog().open).toBe(false); + + rerender( + <AssistantAnswer + view={{ + status: 'done', + message: { + id: 'm1', + role: 'assistant', + content: 'Revenue', + toolCalls: [{ id: 't1', name: 'list_paragraphs' }] + } + }} + /> + ); + expect(toolLog().open).toBe(false); + }); + + it('labels a tool whose completion event carried no name', () => { + render(<AssistantAnswer view={{ status: 'streaming', content: '', tools: [{ id: 't1', status: 'running' }] }} />); + 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(<AssistantAnswer view={view} />); + 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( + <AssistantAnswer + view={{ status: 'done', message: { id: 'm1', role: 'assistant', content: '**bold**' } }} + renderContent={content => <strong>{content.replaceAll('*', '')}</strong>} + /> + ); + 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 0000000000..b0f6fbecbc --- /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 }) => ( + <div role="status" className={styles.progress}> + <span className={styles.dots} aria-hidden="true"> + <i /> + <i /> + <i /> + </span> + {text} + </div> +); + +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 ( + <AssistantMessageItem role="assistant" content="" streaming> + <Progress text="Thinking…" /> + <Skeleton className={styles.skeleton} active title={false} paragraph={{ rows: 2, width: ['92%', '64%'] }} /> + </AssistantMessageItem> + ); + case 'streaming': { + const tools = view.tools ?? []; + return ( + <AssistantMessageItem role="assistant" content={view.content} streaming renderContent={renderContent}> + <AssistantToolLog tools={tools} /> + <Progress text={progressText(view.content, tools)} /> + </AssistantMessageItem> + ); + } + case 'done': { + const tools = (view.message.toolCalls ?? []).map(call => ({ ...call, status: 'done' as const })); + return ( + <AssistantMessageItem role="assistant" content={view.message.content} renderContent={renderContent}> + <AssistantToolLog tools={tools} /> + </AssistantMessageItem> + ); + } + case 'error': + return ( + <AssistantMessageItem role="assistant" content={view.content ?? ''} renderContent={renderContent}> + <AssistantToolLog tools={view.tools ?? []} /> + <AssistantAnswerError kind={view.kind} message={view.message} onRetry={onRetry} /> + </AssistantMessageItem> + ); + } +}; 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 0000000000..644505c632 --- /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(<AssistantAnswerError kind="failed" message="The model request failed." onRetry={onRetry} />); + 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(<AssistantAnswerError kind="stream" message={new AssistantStreamError().message} onRetry={() => {}} />); + 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( + <AssistantAnswerError kind="connection" message={new AssistantConnectionError().message} onRetry={onRetry} /> + ); + 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(<AssistantAnswerError kind="failed" message="The model request failed." />); + 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 0000000000..ebfb6e80fa --- /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<AssistantAnswerErrorKind, { type: 'error' | 'warning'; action: string }> = { + 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 ( + <Alert + type={type} + showIcon + title={message} + action={ + onRetry && ( + <Button size="small" onClick={onRetry}> + {action} + </Button> + ) + } + /> + ); +}; 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 0000000000..46a0a111a6 --- /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<AssistantToolActivity, 'name'>) => 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 ( + <details className={styles.toolLog} open={running}> + <summary className={styles.toolSummary}>{running ? `Using ${count}` : `Used ${count}`}</summary> + <ul className={styles.toolList}> + {tools.map(tool => ( + <li key={tool.id} className={styles.toolItem} data-status={tool.status}> + {tool.status === 'running' ? ( + <span className={styles.toolIcon} role="img" aria-label="Running"> + <Spin size="small" /> + </span> + ) : ( + <CheckOutlined className={`${styles.toolIcon} ${styles.toolDone}`} aria-label="Done" /> + )} + <span className={styles.toolName}>{toolLabel(tool)}</span> + </li> + ))} + </ul> + </details> + ); +};
