mirror of
https://github.com/apache/superset.git
synced 2026-08-03 20:42:30 +00:00
feat(chat): show a turn's dropped objects above its question
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<ResourceContext['kind'], React.ReactNode> = {
|
||||
dashboard: <DashboardOutlined />,
|
||||
chart: <BarChartOutlined />,
|
||||
dataset: <DatabaseOutlined />,
|
||||
};
|
||||
|
||||
function referenceLabel(reference: ResourceContext): string {
|
||||
const kind: Record<ResourceContext['kind'], string> = {
|
||||
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 => (
|
||||
<Tag
|
||||
<ReferenceTag
|
||||
key={referenceKey(reference)}
|
||||
icon={REFERENCE_ICON[reference.kind]}
|
||||
closable
|
||||
reference={reference}
|
||||
onClose={() => 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)}
|
||||
</Tag>
|
||||
/>
|
||||
))}
|
||||
{dragging && entities.references.length === 0 && (
|
||||
<Typography.Text type="secondary">
|
||||
|
||||
@@ -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(<ChatPanel />);
|
||||
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(<ChatPanel />);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
<Flex
|
||||
wrap
|
||||
gap={theme.marginXXS}
|
||||
style={{ marginBottom: theme.marginXXS }}
|
||||
data-test="chat-message-references"
|
||||
>
|
||||
{item.references.map(reference => (
|
||||
<ReferenceTag
|
||||
key={referenceKey(reference)}
|
||||
reference={reference}
|
||||
linked
|
||||
data-test="chat-message-reference"
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
) : null}
|
||||
<Typography.Text style={{ color: 'inherit' }}>
|
||||
{item.content}
|
||||
</Typography.Text>
|
||||
|
||||
105
extensions/ai-chat/frontend/src/components/ReferenceTag.tsx
Normal file
105
extensions/ai-chat/frontend/src/components/ReferenceTag.tsx
Normal file
@@ -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<ResourceContext['kind'], React.ReactNode> = {
|
||||
dashboard: <DashboardOutlined />,
|
||||
chart: <BarChartOutlined />,
|
||||
dataset: <DatabaseOutlined />,
|
||||
};
|
||||
|
||||
export function referenceLabel(reference: ResourceContext): string {
|
||||
const kind: Record<ResourceContext['kind'], string> = {
|
||||
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 = (
|
||||
<Tag
|
||||
icon={REFERENCE_ICON[reference.kind]}
|
||||
closable={Boolean(onClose)}
|
||||
onClose={onClose}
|
||||
data-test={dataTest}
|
||||
title={label}
|
||||
// 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',
|
||||
// Both rows space their tags with a Flex gap
|
||||
marginInlineEnd: 0,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Tag>
|
||||
);
|
||||
return linked ? (
|
||||
<a
|
||||
href={entityHref(reference)}
|
||||
aria-label={t('Open %s', label)}
|
||||
data-test="chat-reference-link"
|
||||
>
|
||||
{tag}
|
||||
</a>
|
||||
) : (
|
||||
tag
|
||||
);
|
||||
}
|
||||
@@ -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([
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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 '';
|
||||
|
||||
Reference in New Issue
Block a user