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..50edf035857 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.spec.tsx @@ -0,0 +1,180 @@ +/* + * 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 completed answer rounds in order and shows tools from every round', () => { + render( + + ); + + 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']); + }); + + 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('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('*', '')}} + /> + ); + expect(answer().querySelector('strong')?.textContent).toBe('bold'); + }); +}); 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..dc472e26962 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.stories.tsx @@ -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 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', segments: [{ id: 's1', content: reply }] } }, + completedWithTools: { + status: 'done', + message: { + id: 'a1', + role: 'assistant', + segments: [ + { + id: 's1', + content: '', + toolCalls: [ + { id: 't1', name: 'list_paragraphs' }, + { id: 't2', name: 'list_paragraphs' } + ] + }, + { id: 's2', content: reply } + ] + } + }, + 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, + tools: [{ id: 't1', name: 'read_results', status: 'running' }] + } +} 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/AssistantAnswer.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.tsx new file mode 100644 index 00000000000..1139c73b2eb --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantAnswer.tsx @@ -0,0 +1,84 @@ +/* + * 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 { answerText, answerToolCalls } from '../model/messageHistory'; +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 = answerToolCalls(view.message).map(call => ({ ...call, status: 'done' as const })); + return ( + + + + ); + } + 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/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/AssistantMessageItem.css.ts b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.css.ts new file mode 100644 index 00000000000..3ec15837120 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.css.ts @@ -0,0 +1,93 @@ +/* + * 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 { 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. 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 new file mode 100644 index 00000000000..d436f221644 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.spec.tsx @@ -0,0 +1,42 @@ +/* + * 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(); + }); + + 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.stories.tsx b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.stories.tsx new file mode 100644 index 00000000000..69ba1ccff49 --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantMessageItem.stories.tsx @@ -0,0 +1,66 @@ +/* + * 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: () => ( +
+ + +
+ ) +}; 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..fa6203db258 --- /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'; +import { MarkdownAnswer } from './MarkdownAnswer'; + +const renderMarkdown: AssistantContentRenderer = 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 = renderMarkdown, + children +}: AssistantMessageItemProps) => { + if (role === 'user') { + return ( +
+
You
+
{content}
+
+ ); + } + return ( +
+
+ + Assistant +
+ {content && ( +
+ {renderContent(content)} +
+ )} + {children} +
+ ); +}; 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..aa1359733ab --- /dev/null +++ b/zeppelin-web-angular/projects/zeppelin-react/src/entities/assistant/ui/AssistantToolLog.tsx @@ -0,0 +1,53 @@ +/* + * 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, QuestionCircleOutlined } 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 unknown = tools.some(tool => tool.status === 'unknown'); + const count = `${tools.length} ${tools.length === 1 ? 'tool' : 'tools'}`; + return ( +
+ + {running ? `Using ${count}` : unknown ? `Used ${count} (status unknown)` : `Used ${count}`} + +
    + {tools.map(tool => ( +
  • + {tool.status === 'running' ? ( + + + + ) : tool.status === 'unknown' ? ( + + ) : ( + + )} + {toolLabel(tool)} +
  • + ))} +
+
+ ); +}; 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..164a8ed8d5a --- /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 { 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' | 'unknown'; +} + +/** + * 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: AssistantAnswerMessage } + | { + status: 'error'; + kind: AssistantAnswerErrorKind; + message: string; + /** The part of the answer that arrived before it stopped. */ + content?: string; + tools?: AssistantToolActivity[]; + }; + +/** Renders an answer body. Defaults to `MarkdownAnswer`; a parent can pass one that resolves paragraph references. */ +export type AssistantContentRenderer = (content: string) => ReactNode; 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 = {};