diff --git a/extensions/ai-chat/README.md b/extensions/ai-chat/README.md index ee6676b8ad8..4daee97fbd1 100644 --- a/extensions/ai-chat/README.md +++ b/extensions/ai-chat/README.md @@ -342,7 +342,9 @@ provider-agnostic and needs no changes. same-origin URLs are read. They stay attached until removed with the chip's X or until the conversation is cleared, and travel as page context on every turn — so they are hints the assistant verifies with a tool, exactly like - the page's own resource. + the page's own resource. Each message records the objects it was sent with + above the question, as links back to them: a snapshot of the context that + turn carried, kept even after the object is detached. - **Attachments live in the turn**: file text counts toward `MAX_INPUT_CHARS`, and both files and images leave context once history diff --git a/extensions/ai-chat/frontend/src/components/ChatInput.tsx b/extensions/ai-chat/frontend/src/components/ChatInput.tsx index a3970e1e26a..0055bf23cc2 100644 --- a/extensions/ai-chat/frontend/src/components/ChatInput.tsx +++ b/extensions/ai-chat/frontend/src/components/ChatInput.tsx @@ -28,10 +28,7 @@ import { Typography, } from 'antd'; import { - BarChartOutlined, CloseCircleFilled, - DatabaseOutlined, - DashboardOutlined, EyeOutlined, PaperClipOutlined, PlusOutlined, @@ -48,27 +45,11 @@ import { } from '../utils/attachments'; import { droppedText, referenceKey } from '../utils/entityRef'; import type { EntityReferences } from '../hooks/useEntityReferences'; -import type { ResourceContext } from '../types'; +import ReferenceTag from './ReferenceTag'; const { t } = translation; const { useTheme } = theme; -const REFERENCE_ICON: Record = { - dashboard: , - chart: , - dataset: , -}; - -function referenceLabel(reference: ResourceContext): string { - const kind: Record = { - dashboard: t('Dashboard'), - chart: t('Chart'), - dataset: t('Dataset'), - }; - // Until the name resolves, the id is what identifies it. - return reference.name || `${kind[reference.kind]} ${reference.id_or_slug}`; -} - interface ChatInputProps { disabled: boolean; busy: boolean; @@ -191,27 +172,12 @@ export default function ChatInput({ data-test="chat-references" > {entities.references.map(reference => ( - entities.remove(referenceKey(reference))} data-test="chat-reference" - title={referenceLabel(reference)} - // Same tokens as the host's secondary button, matching the - // scope tag in the header. Set through `style` rather than - // Tag's `color` prop, which pairs a custom background with - // white text. - style={{ - color: theme.buttonSecondaryColor || theme.colorPrimary, - background: theme.buttonSecondaryBg || theme.colorPrimaryBg, - borderColor: theme.buttonSecondaryBorderColor || 'transparent', - maxWidth: 200, - overflow: 'hidden', - }} - > - {referenceLabel(reference)} - + /> ))} {dragging && entities.references.length === 0 && ( diff --git a/extensions/ai-chat/frontend/src/components/ChatPanel.test.tsx b/extensions/ai-chat/frontend/src/components/ChatPanel.test.tsx index e6626cf46cd..7520a27628e 100644 --- a/extensions/ai-chat/frontend/src/components/ChatPanel.test.tsx +++ b/extensions/ai-chat/frontend/src/components/ChatPanel.test.tsx @@ -557,6 +557,11 @@ test('dragging a chart in pins it as context for every later message', async () { kind: 'chart', id_or_slug: '100' }, ]); + // The question records what it was asked about, linked to the chart. + const tag = screen.getByTestId('chat-message-reference'); + expect(tag).toHaveTextContent('Chart 100'); + expect(tag.closest('a')).toHaveAttribute('href', '/explore/?slice_id=100'); + // Still attached for the next question: that is the point of dropping it. await userEvent.type(input, 'and now?{Enter}'); await waitFor(() => @@ -586,6 +591,34 @@ test('a dropped object can be removed, and duplicates are ignored', async () => ); }); +test('a question keeps the context it was asked with once detached', async () => { + mockConfigAndChat(ENABLED_CONFIG, [ + { type: 'message.completed', id: 'm1', content: 'Sure.' }, + { type: 'request.completed' }, + ]); + render(); + const input = await screen.findByTestId('chat-input'); + await waitFor(() => expect(input).toBeEnabled()); + + dropUrl( + screen.getByTestId('chat-composer'), + '/superset/dashboard/world_health/', + ); + await screen.findByTestId('chat-reference'); + await userEvent.type(input, 'what is in here?{Enter}'); + await screen.findByText('Sure.'); + + // Detaching stops it riding along with later turns. The transcript still + // shows what this one carried, since that is what the model was asked. + await userEvent.click(screen.getByLabelText('Close')); + await waitFor(() => + expect(screen.queryByTestId('chat-reference')).toBeNull(), + ); + const tag = screen.getByTestId('chat-message-reference'); + expect(tag).toHaveTextContent('Dashboard world_health'); + expect(tag.closest('a')).toHaveAttribute('href', '/dashboard/world_health/'); +}); + test('dropping something that is not a Superset object explains itself', async () => { mockConfigAndChat(ENABLED_CONFIG, []); render(); diff --git a/extensions/ai-chat/frontend/src/components/ChatPanel.tsx b/extensions/ai-chat/frontend/src/components/ChatPanel.tsx index 2794761ff05..eb84bd8fc04 100644 --- a/extensions/ai-chat/frontend/src/components/ChatPanel.tsx +++ b/extensions/ai-chat/frontend/src/components/ChatPanel.tsx @@ -113,6 +113,7 @@ export default function ChatPanel() { content, sent, attachments: attachmentRefs(attachments), + references: entities.references, images, }); run(signal => @@ -186,6 +187,7 @@ export default function ChatPanel() { data-test="ai-chat-panel" vertical style={{ + boxSizing: 'border-box', width: mode === 'panel' ? '100%' : 600, height: mode === 'panel' ? '100%' : 'min(760px, 90vh)', background: theme.colorBgElevated, diff --git a/extensions/ai-chat/frontend/src/components/MessageList.tsx b/extensions/ai-chat/frontend/src/components/MessageList.tsx index fa14c831001..267be501f18 100644 --- a/extensions/ai-chat/frontend/src/components/MessageList.tsx +++ b/extensions/ai-chat/frontend/src/components/MessageList.tsx @@ -20,8 +20,10 @@ import React, { useEffect, useRef } from 'react'; import { Flex, Image, Spin, Tag, Typography } from 'antd'; import { PaperClipOutlined } from '@ant-design/icons'; import { theme, translation } from '@apache-superset/core'; +import { referenceKey } from '../utils/entityRef'; import type { DisplayItem, FoldSignal } from '../types'; import AssistantMessage from './AssistantMessage'; +import ReferenceTag from './ReferenceTag'; import ToolCallCard from './ToolCallCard'; const { t } = translation; @@ -69,6 +71,25 @@ function MessageBubble({ overflowWrap: 'break-word', }} > + {item.references?.length ? ( + // Above the question, as they were in the composer when it was + // asked, and linked so the object is one click away + + {item.references.map(reference => ( + + ))} + + ) : null} {item.content} diff --git a/extensions/ai-chat/frontend/src/components/ReferenceTag.tsx b/extensions/ai-chat/frontend/src/components/ReferenceTag.tsx new file mode 100644 index 00000000000..cc966a10afa --- /dev/null +++ b/extensions/ai-chat/frontend/src/components/ReferenceTag.tsx @@ -0,0 +1,105 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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 React from 'react'; +import { Tag } from 'antd'; +import { + BarChartOutlined, + DashboardOutlined, + DatabaseOutlined, +} from '@ant-design/icons'; +import { theme, translation } from '@apache-superset/core'; +import { entityHref } from '../utils/entityRef'; +import type { ResourceContext } from '../types'; + +const { t } = translation; +const { useTheme } = theme; + +const REFERENCE_ICON: Record = { + dashboard: , + chart: , + dataset: , +}; + +export function referenceLabel(reference: ResourceContext): string { + const kind: Record = { + dashboard: t('Dashboard'), + chart: t('Chart'), + dataset: t('Dataset'), + }; + // Until the name resolves, the id is what identifies it. + return reference.name || `${kind[reference.kind]} ${reference.id_or_slug}`; +} + +interface ReferenceTagProps { + reference: ResourceContext; + /** Detaches it; omitted where the tag records what a turn already carried */ + onClose?: () => void; + /** Turns the whole chip into a link to the object it names */ + linked?: boolean; + 'data-test'?: string; +} + +/** + * One dropped dashboard, chart or dataset, shown the same way wherever it + * appears: staged in the composer, where it can be detached, and above the + * message it was sent with, where it links to the object instead. + */ +export default function ReferenceTag({ + reference, + onClose, + linked, + 'data-test': dataTest, +}: ReferenceTagProps) { + const theme = useTheme(); + const label = referenceLabel(reference); + const tag = ( + + {label} + + ); + return linked ? ( + + {tag} + + ) : ( + tag + ); +} diff --git a/extensions/ai-chat/frontend/src/state/conversation.ts b/extensions/ai-chat/frontend/src/state/conversation.ts index c1b0cbc033a..9960419444b 100644 --- a/extensions/ai-chat/frontend/src/state/conversation.ts +++ b/extensions/ai-chat/frontend/src/state/conversation.ts @@ -32,6 +32,7 @@ import type { PendingApproval, ProtocolImage, ProtocolMessage, + ResourceContext, } from '../types'; export const MAX_HISTORY_MESSAGES = 60; @@ -66,6 +67,8 @@ export type ConversationAction = */ sent?: string; attachments?: AttachmentRef[]; + /** Dropped objects this turn carried, recorded beside the message */ + references?: ResourceContext[]; images?: ProtocolImage[]; } | { type: 'events'; events: ChatEvent[] } @@ -322,6 +325,9 @@ export function conversationReducer( ...(action.attachments?.length ? { attachments: action.attachments } : {}), + ...(action.references?.length + ? { references: action.references } + : {}), }, ], history: trimHistory([ diff --git a/extensions/ai-chat/frontend/src/types.ts b/extensions/ai-chat/frontend/src/types.ts index a94fd9ba484..8ad42e0f5cc 100644 --- a/extensions/ai-chat/frontend/src/types.ts +++ b/extensions/ai-chat/frontend/src/types.ts @@ -184,6 +184,12 @@ export type DisplayItem = content: string; /** File names shown in the transcript; their text lives in history */ attachments?: AttachmentRef[]; + /** + * Objects that were attached when the turn was sent. A snapshot, not a + * view of what is attached now: the transcript records the context each + * question actually carried, and references come and go between turns. + */ + references?: ResourceContext[]; } | { kind: 'tool'; diff --git a/extensions/ai-chat/frontend/src/utils/entityRef.test.ts b/extensions/ai-chat/frontend/src/utils/entityRef.test.ts index d62d8e89fa0..51a2a60136c 100644 --- a/extensions/ai-chat/frontend/src/utils/entityRef.test.ts +++ b/extensions/ai-chat/frontend/src/utils/entityRef.test.ts @@ -16,7 +16,12 @@ * specific language governing permissions and limitations * under the License. */ -import { droppedText, parseEntityUrl, referenceKey } from './entityRef'; +import { + droppedText, + entityHref, + parseEntityUrl, + referenceKey, +} from './entityRef'; test('a chart title dragged from a dashboard names its chart', () => { // The href Superset renders on a dashboard chart header. @@ -73,6 +78,27 @@ test('references are keyed by kind and id', () => { expect(referenceKey({ kind: 'chart', id_or_slug: '100' })).toBe('chart:100'); }); +test('every reference links back to what it names', () => { + const references = [ + { kind: 'dashboard', id_or_slug: 'world_health' }, + { kind: 'dashboard', id_or_slug: '5' }, + { kind: 'chart', id_or_slug: '100' }, + { kind: 'dataset', id_or_slug: '42' }, + ] as const; + // Whatever could be attached can be opened: the link parses back to it. + references.forEach(reference => + expect(parseEntityUrl(entityHref(reference))).toEqual(reference), + ); + expect(entityHref(references[0])).toBe('/dashboard/world_health/'); + expect(entityHref(references[2])).toBe('/explore/?slice_id=100'); +}); + +test('a slug is escaped rather than trusted into the link', () => { + expect(entityHref({ kind: 'dashboard', id_or_slug: '../../evil?x=1' })).toBe( + '/dashboard/..%2F..%2Fevil%3Fx%3D1/', + ); +}); + test('a drop prefers the uri-list flavour over plain text', () => { const transfer = { getData: (type: string) => diff --git a/extensions/ai-chat/frontend/src/utils/entityRef.ts b/extensions/ai-chat/frontend/src/utils/entityRef.ts index 59089310e9d..5b8b6ce054b 100644 --- a/extensions/ai-chat/frontend/src/utils/entityRef.ts +++ b/extensions/ai-chat/frontend/src/utils/entityRef.ts @@ -96,6 +96,27 @@ export function parseEntityUrl(raw: string): ResourceContext | null { return null; } +/** + * Where a reference points, in the same form Superset's own models build, so + * a tag in the transcript opens what the user dropped. Kept as a path so the + * link can only lead back into this instance. + * + * `parseEntityUrl` reads these back, which is what the round-trip test pins: + * a reference that could be attached is a reference that can be opened. + */ +export function entityHref(reference: ResourceContext): string { + const id = encodeURIComponent(reference.id_or_slug); + switch (reference.kind) { + case 'dashboard': + return `/dashboard/${id}/`; + case 'chart': + return `/explore/?slice_id=${id}`; + // Only table datasources are ever parsed into a reference. + default: + return `/explore/?datasource_type=table&datasource_id=${id}`; + } +} + /** The URL text a drop carries, in the order browsers prefer. */ export function droppedText(transfer: DataTransfer | null): string { if (!transfer) return '';