sadpandajoe commented on code in PR #43133: URL: https://github.com/apache/superset/pull/43133#discussion_r3870376230
########## superset-frontend/src/features/ai/components/ChatChartEmbed.tsx: ########## @@ -0,0 +1,542 @@ +/** + * 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. + */ + +/** + * @fileoverview A chart rendered inside a chat message. + * + * The assistant does not send a chart, it sends a `form_data_key` it stored — so + * what arrives in the transcript is a reference the client resolves, and the + * rendered chart is the real thing, with the real permissions, rather than an + * image of one. + * + * The awkward part is timing: the key can exist before the query behind it has + * finished. Rather than show the chart's own "No data" state (which reads as a + * broken answer) the component keeps a spinner up and re-renders on a growing + * backoff until rows appear, giving up after a bounded number of attempts. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + type QueryFormData, + StatefulChart, + SupersetClient, +} from '@superset-ui/core'; +import { styled } from '@apache-superset/core/theme'; +import { t } from '@apache-superset/core/translation'; +import { Loading } from '@superset-ui/core/components'; +import { Icons } from '@superset-ui/core/components/Icons'; +import { ErrorBoundary } from 'src/components/ErrorBoundary'; + +const VALID_KEY_PATTERN = /^[a-zA-Z0-9_-]+$/; +const MIN_HEIGHT = 100; +const MAX_HEIGHT = 800; +const DEFAULT_HEIGHT = 300; +const FETCH_TIMEOUT_MS = 30_000; +const MAX_RETRIES = 3; +const RETRY_DELAYS_MS = [500, 1500, 3000]; + +/** Width used until the container has been measured. */ +const FALLBACK_CHART_WIDTH = 600; + +// Backoff for the "waiting for chart data" poll. The delay grows exponentially +// per attempt up to a ceiling, so a slow query is waited out without hammering +// the backend. +const POLL_BASE_DELAY_MS = 1000; +const POLL_MAX_DELAY_MS = 30_000; + +/** + * Polling stops after this many attempts. + * + * A retry re-issues the chart's data request, which will use the results cache if + * the query has landed but will otherwise execute it. Polling forever would keep + * re-issuing it, so an unfinished query surfaces the retry control instead. + */ +const MAX_POLL_ATTEMPTS = 6; + +const getPollDelayMs = (attempt: number): number => + Math.min(POLL_BASE_DELAY_MS * 2 ** attempt, POLL_MAX_DELAY_MS); + +export interface ChartEmbedParams { + formDataKey: string | null; + height: number; + title: string | null; +} + +/** + * Parse key=value lines from the content of a ```superset-chart fenced block. + * + * Rules are strict on purpose: the block is model output, so `form_data_key` must + * match `/^[a-zA-Z0-9_-]+$/` before it reaches a URL, and `height` is clamped. + * Unknown keys are ignored so a newer backend can add some without breaking an + * older client. + */ +export function parseChartEmbedParams(codeText: string): ChartEmbedParams { + const result: ChartEmbedParams = { + formDataKey: null, + height: DEFAULT_HEIGHT, + title: null, + }; + + const lines = codeText + .split('\n') + .map(line => line.trim()) + .filter(Boolean); + + lines.forEach(line => { + const eqIndex = line.indexOf('='); + if (eqIndex <= 0) { + return; + } + + const key = line.slice(0, eqIndex).trim().toLowerCase(); + const value = line.slice(eqIndex + 1).trim(); + + if (key === 'form_data_key') { + if (value && VALID_KEY_PATTERN.test(value)) { + result.formDataKey = value; + } + return; + } + if (key === 'height') { + const parsed = parseInt(value, 10); + if (!Number.isNaN(parsed)) { + result.height = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, parsed)); + } + return; + } + if (key === 'title' && value) { + result.title = value; + } + }); + + return result; +} + +const ChartContainer = styled.div` + border: 1px solid ${({ theme }) => theme.colorBorderSecondary}; + border-radius: ${({ theme }) => theme.borderRadius}px; + overflow: hidden; + margin: ${({ theme }) => theme.sizeUnit * 2}px 0; + background: ${({ theme }) => theme.colorBgContainer}; +`; + +const ChartHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + padding: ${({ theme }) => theme.sizeUnit * 2}px + ${({ theme }) => theme.sizeUnit * 3}px; + border-bottom: 1px solid ${({ theme }) => theme.colorBorderSecondary}; + background: ${({ theme }) => theme.colorBgLayout}; + font-size: ${({ theme }) => theme.fontSizeSM}px; +`; + +const ChartTitle = styled.span` + font-weight: ${({ theme }) => theme.fontWeightStrong}; + color: ${({ theme }) => theme.colorText}; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; +`; + +const ChartActions = styled.div` + display: flex; + gap: ${({ theme }) => theme.sizeUnit * 2}px; + align-items: center; + flex-shrink: 0; + margin-left: ${({ theme }) => theme.sizeUnit * 2}px; +`; + +const ActionLink = styled.a` + display: inline-flex; + align-items: center; + gap: ${({ theme }) => theme.sizeUnit / 2}px; + font-size: ${({ theme }) => theme.fontSizeSM}px; + color: ${({ theme }) => theme.colorPrimary}; + cursor: pointer; + text-decoration: none; + + &:hover { + text-decoration: underline; + } +`; + +const ActionButton = styled.button` + display: inline-flex; + align-items: center; + gap: ${({ theme }) => theme.sizeUnit / 2}px; + font-size: ${({ theme }) => theme.fontSizeSM}px; + color: ${({ theme }) => theme.colorTextSecondary}; + cursor: pointer; + background: none; + border: none; + padding: 2px ${({ theme }) => theme.sizeUnit / 2}px; + border-radius: ${({ theme }) => theme.borderRadius}px; + + &:hover { + color: ${({ theme }) => theme.colorPrimary}; + background: ${({ theme }) => theme.colorFillTertiary}; + } +`; + +const ChartBody = styled.div<{ height: number }>` + height: ${({ height }) => height}px; + position: relative; +`; + +// Covers the chart while it reports no data, hiding the underlying "No data" +// state (which looks broken) behind a spinner while refreshing continues. +const ChartDataOverlay = styled.div` + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: ${({ theme }) => theme.sizeUnit * 3}px; + background: ${({ theme }) => theme.colorBgContainer}; + color: ${({ theme }) => theme.colorTextSecondary}; + font-size: ${({ theme }) => theme.fontSizeSM}px; + z-index: 2; +`; + +const CenteredMessage = styled.div<{ height: number }>` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: ${({ height }) => height}px; + color: ${({ theme }) => theme.colorTextSecondary}; + font-size: ${({ theme }) => theme.fontSizeSM}px; + text-align: center; + padding: ${({ theme }) => theme.sizeUnit * 4}px; + gap: ${({ theme }) => theme.sizeUnit * 2}px; +`; + +interface ChatChartEmbedProps { + formDataKey: string; + height?: number; + title?: string; +} + +type FetchState = + | { status: 'loading' } + | { status: 'loaded'; formData: QueryFormData } + | { status: 'error'; message: string }; + +const exploreUrlFor = (formDataKey: string): string => Review Comment: This absolute Explore URL drops the application root. Deployments served under a prefix therefore send the new “View in Explore” link to `/explore/…` instead of the mounted route. Could this use the existing application-root-aware URL helper? ########## superset-frontend/src/features/ai/AiAssistantPanel.tsx: ########## @@ -0,0 +1,1150 @@ +/** + * 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. + */ + +/** + * @fileoverview The assistant panel. + * + * The host owns where this sits and, when docked, how wide it is, so there is no + * positioning and no resize handle here. What is here is the conversation: the + * header, the transcript, what the assistant is doing while it works, and the + * composer. + * + * The centre of the design is that a run is legible while it happens. An answer + * can take a minute of tool calls, and a spinner for a minute is indistinguishable + * from a hang, so reasoning streams into a preview, each step appends to a tool + * log, and a checkpoint stops the run with a countdown the user can act on. + */ + +import { + memo, + useCallback, + useEffect, + useRef, + useState, + useSyncExternalStore, +} from 'react'; +import type { Dispatch, SetStateAction } from 'react'; +import ReactMarkdown from 'react-markdown'; +import type { Components } from 'react-markdown'; +import { css, keyframes, styled, useTheme } from '@apache-superset/core/theme'; +import { t } from '@apache-superset/core/translation'; +import type { chat as chatApi } from '@apache-superset/core'; +import { + Button, + Input, + Loading, + Tooltip, + Typography, +} from '@superset-ui/core/components'; +import { Icons } from '@superset-ui/core/components/Icons'; +import { chat } from 'src/core/chat'; +import ChatAgentSelect from './components/ChatAgentSelect'; +import ChatTabsMenu from './components/ChatTabsMenu'; +import { REMARK_PLUGINS, useChatMarkdown } from './components/chatMarkdown'; +import { ThoughtProcess } from './components/ThoughtProcess'; +import { useChatBot } from './hooks/useChatBot'; +import { AI_ACTION_EVENT, type AiActionEvent } from './hooks/useAIAction'; +import type { PageContext } from './hooks/usePageContext'; +import type { ChatMessageWithMeta, CheckpointPayload } from './types'; + +/** + * How long a checkpoint waits before continuing on its own. A pause that blocks + * forever is worse than one that resolves optimistically: the user may have + * walked away, and the run should not be stranded. + */ +export const CHECKPOINT_TIMEOUT_SECONDS = 30; + +/** + * Closes a code fence the model has not finished writing. + * + * A streamed answer is parsed on every delta, so a fence arrives in pieces — + * "```", then "sql", then the query. Markdown with an odd number of fences + * renders the opening backticks literally and then reflows once the closing pair + * lands, which reads as the answer glitching. Balancing the count keeps each + * intermediate state a valid document. + */ +export const balanceCodeFences = (text: string): string => { + const fences = text.match(/^```/gm)?.length ?? 0; + return fences % 2 === 0 ? text : `${text}\n\`\`\``; +}; + +/** + * Whether a message carries the structured record of how it was answered, as + * opposed to only the flat log assembled from stream frames. + */ +const hasStructuredThinking = (message: ChatMessageWithMeta): boolean => + Boolean(message.toolCalls?.length || message.thoughts || message.pageContext); + +/** Milliseconds between typewriter frames, and characters per frame. */ +const TYPEWRITER_INTERVAL_MS = 18; +const TYPEWRITER_STEP = 3; + +/** + * The panel's own size as a floating overlay. + * + * Docked width belongs to the host and is not set here. Floating does need a size + * from somewhere, though — the floating host only stacks its children in a corner + * and gives them no dimensions — so these clamp the overlay to the viewport. + */ +const FLOATING_WIDTH_PX = 440; +const FLOATING_MAX_HEIGHT_VH = 70; + +/** + * The panel surface. + * + * Positioning is deliberately absent: the host places this, in both modes. What is + * here is the surface itself — a column that fills whatever box it is given, with a + * floating size for the mode where the host provides no box. + */ +const ChatPanelContainer = styled.div<{ floating: boolean }>` + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; + background: ${({ theme }) => theme.colorBgElevated}; + ${({ floating, theme }) => + floating + ? css` + width: min( + ${FLOATING_WIDTH_PX}px, + calc(100vw - ${theme.sizeUnit * 12}px) + ); + height: ${FLOATING_MAX_HEIGHT_VH}vh; + border: 1px solid ${theme.colorBorderSecondary}; + border-radius: ${theme.borderRadiusLG}px; + box-shadow: ${theme.boxShadow}; + ` + : css` + width: 100%; + height: 100%; + `} +`; + +const ChatHeader = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + padding: ${({ theme }) => theme.sizeUnit * 3}px + ${({ theme }) => theme.sizeUnit * 4}px; + background: ${({ theme }) => theme.colorBgContainer}; + border-bottom: 1px solid ${({ theme }) => theme.colorBorderSecondary}; + font-weight: ${({ theme }) => theme.fontWeightStrong}; + font-size: ${({ theme }) => theme.fontSizeLG}px; + color: ${({ theme }) => theme.colorTextHeading}; + flex-shrink: 0; + gap: ${({ theme }) => theme.sizeUnit * 2}px; +`; + +const HeaderGroup = styled.div` + display: flex; + align-items: center; + min-width: 0; +`; + +const HeaderTitle = styled.span` + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const ChatMessages = styled.div` + flex: 1; + min-height: 0; + padding: ${({ theme }) => theme.sizeUnit * 4}px; + overflow-y: auto; + + &::-webkit-scrollbar { + width: 4px; + } + + &::-webkit-scrollbar-track { + background: ${({ theme }) => theme.colorBgContainer}; + border-radius: 2px; + } + + &::-webkit-scrollbar-thumb { + background: ${({ theme }) => theme.colorFillSecondary}; + border-radius: 2px; + } +`; + +const MessageBubble = styled.div<{ variant: 'user' | 'assistant' }>` + margin-bottom: ${({ theme }) => theme.sizeUnit * 3}px; + display: flex; + flex-direction: column; + align-items: ${({ variant }) => + variant === 'user' ? 'flex-end' : 'flex-start'}; +`; + +const MessageContent = styled.div<{ variant: 'user' | 'assistant' }>` + max-width: 85%; + padding: ${({ theme }) => theme.sizeUnit * 3}px + ${({ theme }) => theme.sizeUnit * 4}px; + border-radius: ${({ theme }) => theme.borderRadiusLG * 2}px; + background: ${({ theme, variant }) => + variant === 'user' ? theme.colorPrimary : theme.colorBgContainer}; + color: ${({ theme, variant }) => + variant === 'user' ? theme.colorTextLightSolid : theme.colorText}; + font-size: ${({ theme }) => theme.fontSize}px; + line-height: 1.5; + border: ${({ theme, variant }) => + variant === 'assistant' + ? `1px solid ${theme.colorBorderSecondary}` + : 'none'}; + box-shadow: ${({ theme }) => theme.boxShadowTertiary}; + overflow-wrap: anywhere; + + p { + margin: 0; + } + + p:not(:last-child) { + margin-bottom: ${({ theme }) => theme.sizeUnit * 2}px; + } + + a { + color: ${({ theme, variant }) => + variant === 'user' ? theme.colorTextLightSolid : theme.colorPrimary}; + text-decoration: underline; + } + + code { + background: ${({ theme, variant }) => + variant === 'user' ? theme.colorPrimaryActive : theme.colorFillTertiary}; + padding: 2px ${({ theme }) => theme.sizeUnit * 1.5}px; + border-radius: ${({ theme }) => theme.borderRadius}px; + font-size: ${({ theme }) => theme.fontSizeSM}px; + font-family: ${({ theme }) => theme.fontFamilyCode}; + } + + pre { + background: ${({ theme }) => theme.colorFillQuaternary}; + padding: ${({ theme }) => theme.sizeUnit * 3}px; + border-radius: ${({ theme }) => theme.borderRadius}px; + overflow-x: auto; + margin: ${({ theme }) => theme.sizeUnit * 2}px 0; + border: 1px solid ${({ theme }) => theme.colorBorderSecondary}; + } + + pre code { + background: none; + padding: 0; + } +`; + +const MessageActions = styled.div` + display: flex; + gap: ${({ theme }) => theme.sizeUnit}px; + margin-top: ${({ theme }) => theme.sizeUnit}px; +`; + +const ActionButton = styled(Button)` + &&& { + padding: 2px ${({ theme }) => theme.sizeUnit * 1.5}px; + height: ${({ theme }) => theme.sizeUnit * 6}px; + font-size: ${({ theme }) => theme.fontSizeSM}px; + } + + /* The recorded verdict keeps its colour while disabled. Both thumbs lock once + a rating exists, and the default disabled grey would hide which one the + user picked — the state matters more here than the affordance. */ + &&&.is-active, + &&&.is-active:disabled, + &&&.is-active[disabled] { + color: ${({ theme }) => theme.colorPrimary}; + } +`; + +const LiveAnswer = styled.div` + margin-top: ${({ theme }) => theme.sizeUnit * 2}px; + color: ${({ theme }) => theme.colorText}; + font-size: ${({ theme }) => theme.fontSize}px; + line-height: 1.5; + overflow-wrap: anywhere; + + p { + margin: 0; + } + + p:not(:last-child) { + margin-bottom: ${({ theme }) => theme.sizeUnit * 2}px; + } +`; + +const thinkingPulse = keyframes` + 0% { + opacity: 0.45; + } + 50% { + opacity: 1; + } + 100% { + opacity: 0.45; + } +`; + +const ThinkingPreview = styled.div<{ isLive?: boolean }>` + color: ${({ theme }) => theme.colorTextTertiary}; + font-size: ${({ theme }) => theme.fontSizeSM}px; + white-space: pre-wrap; + ${({ isLive }) => + isLive && + css` + animation: ${thinkingPulse} 1.8s ease-in-out infinite; + `} +`; + +const ThinkingDetails = styled.details` + margin-bottom: ${({ theme }) => theme.sizeUnit * 2}px; + color: ${({ theme }) => theme.colorTextTertiary}; + font-size: ${({ theme }) => theme.fontSizeSM}px; + + summary { + cursor: pointer; + user-select: none; + color: ${({ theme }) => theme.colorTextTertiary}; + margin-bottom: ${({ theme }) => theme.sizeUnit}px; + } +`; + +const CheckpointDivider = styled.div` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.sizeUnit * 3}px; + margin: ${({ theme }) => theme.sizeUnit * 4}px 0 + ${({ theme }) => theme.sizeUnit * 3}px; + + &::before, + &::after { + content: ''; + flex: 1; + height: 1px; + background: ${({ theme }) => theme.colorBorderSecondary}; + } +`; + +const CountdownBadge = styled.span` + font-size: ${({ theme }) => theme.fontSizeSM}px; + font-weight: ${({ theme }) => theme.fontWeightStrong}; + font-variant-numeric: tabular-nums; + color: ${({ theme }) => theme.colorTextSecondary}; + white-space: nowrap; +`; + +const CheckpointContent = styled.div` + font-size: ${({ theme }) => theme.fontSizeSM}px; + color: ${({ theme }) => theme.colorText}; + line-height: 1.5; +`; + +const CheckpointTaskList = styled.ul` + margin: ${({ theme }) => theme.sizeUnit * 1.5}px 0; + padding-left: ${({ theme }) => theme.sizeUnit * 4.5}px; + font-size: ${({ theme }) => theme.fontSizeSM}px; + color: ${({ theme }) => theme.colorTextSecondary}; + + li { + margin-bottom: 2px; + } +`; + +const CheckpointEstimate = styled.div` + font-size: ${({ theme }) => theme.fontSizeSM}px; + color: ${({ theme }) => theme.colorTextTertiary}; + margin-top: ${({ theme }) => theme.sizeUnit}px; +`; + +const CheckpointActions = styled.div` + display: flex; + gap: ${({ theme }) => theme.sizeUnit * 2}px; + margin-top: ${({ theme }) => theme.sizeUnit * 2.5}px; +`; + +const ChatInput = styled.div` + padding: ${({ theme }) => theme.sizeUnit * 4}px; + border-top: 1px solid ${({ theme }) => theme.colorBorderSecondary}; + background: ${({ theme }) => theme.colorBgContainer}; + flex-shrink: 0; +`; + +const InputContainer = styled.div` + display: flex; + gap: ${({ theme }) => theme.sizeUnit * 2}px; + align-items: flex-end; +`; + +const QuickPromptsRow = styled.div<{ hasContent: boolean }>` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.sizeUnit * 2}px; + flex-wrap: wrap; + margin-bottom: ${({ theme, hasContent }) => + hasContent ? `${theme.sizeUnit * 2.5}px` : '0'}; + min-height: ${({ theme, hasContent }) => + hasContent ? `${theme.sizeUnit * 6}px` : '0'}; +`; + +const QuickPromptChip = styled(Button)` + &&& { + width: fit-content; + max-width: 100%; + height: auto; + white-space: normal; + text-align: left; + line-height: 1.35; + word-break: break-word; + } +`; + +const PageContextRow = styled.div` + display: flex; + align-items: center; + margin-bottom: ${({ theme }) => theme.sizeUnit * 1.5}px; +`; + +const PageContextPill = styled.button<{ isActive: boolean }>` + display: inline-flex; + align-items: center; + gap: ${({ theme }) => theme.sizeUnit * 1.5}px; + padding: 3px ${({ theme }) => theme.sizeUnit * 2}px; + border-radius: ${({ theme }) => theme.borderRadiusLG}px; + border: 1px solid + ${({ theme, isActive }) => + isActive ? theme.colorPrimary : theme.colorBorderSecondary}; + background: ${({ theme, isActive }) => + isActive ? theme.colorPrimaryBg : theme.colorFillQuaternary}; + color: ${({ theme, isActive }) => + isActive ? theme.colorPrimary : theme.colorTextTertiary}; + font-size: ${({ theme }) => theme.fontSizeSM}px; + cursor: pointer; + transition: all ${({ theme }) => theme.motionDurationMid}; + max-width: ${({ theme }) => theme.sizeUnit * 62}px; + white-space: nowrap; + + &:hover { + border-color: ${({ theme }) => theme.colorPrimary}; + } +`; + +const PillLabel = styled.span` + overflow: hidden; + text-overflow: ellipsis; Review Comment: Answer deltas update `liveAnswer`, but it is absent from this effect’s dependencies. After tool activity stops, a long streamed answer can grow below the viewport without the transcript following it. Could this depend on `liveAnswer` too? ########## superset-frontend/src/features/ai/hooks/useChatBot.ts: ########## @@ -0,0 +1,1323 @@ +/** + * 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. + */ + +/** + * @fileoverview Conversation state and the send loop. + * + * Runs are tracked per conversation, not globally. That is the point of the + * structure: a user can start something slow in one conversation, switch to + * another and keep working, and come back to find the first still going. A single + * `isLoading` flag would have made switching away cancel or corrupt the run. + * + * The server owns the transcript. A finished run is re-read from it rather than + * assembled from the frames, so the tool calls persisted on the message are what + * the user sees, and what they see survives a reload. + */ + +import { useCallback, useEffect, useRef, useState } from 'react'; +import type { TextAreaRef } from 'antd/es/input/TextArea'; +import { logging } from '@apache-superset/core/utils'; +import { t } from '@apache-superset/core/translation'; +import { + type AiAgent, + type AiToolCall, + type ChatMessageWithMeta, + type ChatTab, + type CheckpointPayload, +} from '../types'; +import { + AGENT_STORAGE_KEY, + ChatRequestAbortedError, + ChatStreamEventError, + ChatStreamTimeoutError, + DEFAULT_AGENT_KEY, + DEFAULT_CHAT_AGENT, + cancelChatRun, + describeRequestError, + fetchAgents, + fetchSuggestedPrompts, + loadStoredAgentKey, + normalizeChatAgents, + startRun, + streamRun, + submitFeedback, +} from './chatRequest'; +import { + NEW_CHAT_NAME, + createThread, + deleteThread as deleteThreadApi, + getThread, + listThreads, + threadToTab, + updateThread, +} from './chatThreadsApi'; +import { buildQuickPrompts } from './quickPrompts'; +import { + buildPageContextPayload, + usePageContext, + type PageContext, +} from './usePageContext'; + +/** Cache of the conversation list, so the menu renders before the list arrives. */ +export const CHAT_TABS_STORAGE_KEY = 'superset-chat-tabs'; + +/** Which conversation was last open. */ +export const ACTIVE_TAB_STORAGE_KEY = 'superset-chat-active-tab'; + +/** Recent inputs, recalled with the arrow keys. */ +export const HISTORY_STORAGE_KEY = 'superset-chat-history'; + +export { AGENT_STORAGE_KEY } from './chatRequest'; + +/** How many inputs the arrow-key history keeps. */ +const MAX_INPUT_HISTORY = 50; + +/** A conversation title derived from a message is clipped to this. */ +const MAX_TAB_NAME_LENGTH = 30; + +export type ChatRunStatus = 'running' | 'cancelling'; + +/** Shared empty list, so a render with no steps yet keeps a stable identity. */ +const EMPTY_TOOL_CALLS: AiToolCall[] = []; + +interface ActiveChatRun { + requestId: string; + tabId: string; + threadId: string; + runId?: string; + controller: AbortController; + isStreaming: boolean; + liveThoughts: string; + liveToolLog: string; + /** + * Steps taken so far, as structured records rather than log lines. + * + * Carried alongside `liveToolLog` so a run in flight can be rendered the same + * way a finished one is — expandable per step, with the SQL and the rows it + * returned — instead of as a wall of text that only becomes legible once the + * transcript is re-read from the server. + */ + liveToolCalls: AiToolCall[]; + /** The page context this run was given, so the live view can show it too. */ + livePageContext?: string; + /** + * The answer so far, as the model produces it. + * + * Rendered directly: the deltas used to be folded into `liveThinking`, which + * nothing displayed, so an answer appeared in one piece the moment the run + * ended however long it had taken to generate. + */ + liveAnswer: string; + liveThinking: string; + status: ChatRunStatus; + startedAt: number; + checkpoint: CheckpointPayload | null; +} + +/** + * An identifier for a turn. + * + * Drawn from `crypto`, not `Math.random`. These become the idempotency key on a + * turn and the handle used to cancel one, so a value another session could guess + * is a correctness and a security problem rather than merely a collision risk. + */ +const generateId = (): string => { + if (typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + // Older engines expose the entropy source without the convenience wrapper. + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return Array.from(bytes, byte => byte.toString(16).padStart(2, '0')).join(''); +}; + +const createNewTab = (name: string = NEW_CHAT_NAME): ChatTab => ({ + id: generateId(), + name, + messages: [], + createdAt: Date.now(), +}); + +const truncateTabName = ( + name: string, + maxLength: number = MAX_TAB_NAME_LENGTH, +): string => + name.length <= maxLength ? name : `${name.substring(0, maxLength)}...`; + +const readJson = <T>(key: string, fallback: T): T => { + try { + const stored = localStorage.getItem(key); + return stored ? (JSON.parse(stored) as T) : fallback; + } catch (caught) { + logging.warn(`[ai] could not read ${key}`, caught); + return fallback; + } +}; + +const writeJson = (key: string, value: unknown): void => { + try { + localStorage.setItem(key, JSON.stringify(value)); + } catch (caught) { + logging.warn(`[ai] could not write ${key}`, caught); + } +}; + +/** + * Reconciles the server's transcript with what is already on screen. + * + * The server's copy is authoritative — it carries the tool calls — but it is not + * necessarily complete the moment a run ends, and replacing outright would then + * erase an answer the user has just read. So anything local that the server has + * not accounted for is kept, matched by identity first and by role and content + * second, which is how a locally-appended turn is recognised once the server + * returns its own copy of it under a real uuid. + */ +export const mergeMessages = ( + fromServer: ChatMessageWithMeta[], + local: ChatMessageWithMeta[], +): ChatMessageWithMeta[] => { + const serverIds = new Set(fromServer.map(message => message.id)); + const serverTurns = new Set( + fromServer.map(message => `${message.role}:${message.content}`), + ); + const unaccounted = local.filter( + message => + !serverIds.has(message.id) && + !serverTurns.has(`${message.role}:${message.content}`), + ); + return [...fromServer, ...unaccounted]; +}; + +/** + * The `page_context` body for one turn. + * + * Returns undefined when there is nothing to send, so an omitted field is + * distinguishable from an empty one. + */ +export const buildRequestPageContext = ( + context: PageContext | undefined, + directive?: string, +): Record<string, unknown> | undefined => { + const payload = context ? buildPageContextPayload(context) : undefined; + if (!directive) { + return payload; + } + const existing = payload?.helper_directives; + return { + ...payload, + helper_directives: [ + directive, + ...(Array.isArray(existing) ? existing : []), + ], + }; +}; + +export interface UseChatBotReturn { + // Conversations + chatTabs: ChatTab[]; + activeTabId: string; + activeTab: ChatTab | undefined; + threadsLoaded: boolean; + handleNewChat: () => Promise<string>; + handleSelectTab: (tabId: string) => Promise<void>; + handleDeleteTab: (tabId: string) => Promise<void>; + handleRenameTab: (tabId: string, newName: string) => void; + // Messages of the active conversation + messages: ChatMessageWithMeta[]; + // Input + inputValue: string; + setInputValue: (value: string) => void; + handleKeyDown: (event: React.KeyboardEvent) => void; + inputRef: React.RefObject<TextAreaRef>; + messagesEndRef: React.RefObject<HTMLDivElement>; + // The run in flight, if any, for the active conversation + isLoading: boolean; + isStreamingResponse: boolean; + liveThoughts: string; + liveToolLog: string; + /** Steps taken so far in the run in flight, for the structured live view. */ + liveToolCalls: AiToolCall[]; + /** The page context the run in flight was given. */ + livePageContext?: string; + /** The answer so far for the run in flight. */ + liveAnswer: string; + checkpoint: CheckpointPayload | null; + activeRunStatus: ChatRunStatus | null; + error?: string; + // Actions + sendMessage: ( + messageOverride?: string, + systemPromptOverride?: string, + ) => Promise<void>; + handleCancelRun: () => Promise<void>; + handleCheckpointContinue: () => void; + handleFeedback: (messageId: string, feedback: 'like' | 'dislike') => void; + messageFeedback: Record<string, 'like' | 'dislike'>; + // Suggestions + /** The message whose run just ended; its thought process stays open. */ + justCompletedId?: string; + quickPrompts: string[]; + loadQuickPrompts: () => void; + applyQuickPrompt: (prompt: string) => Promise<void>; + // Agent profiles + agents: AiAgent[]; + selectedAgent: string; + setSelectedAgent: (key: string) => void; + // Page context + pageContext: PageContext; + includePageContext: boolean; + toggleIncludePageContext: () => void; +} + +export const useChatBot = (): UseChatBotReturn => { + const [chatTabs, setChatTabs] = useState<ChatTab[]>(() => + readJson<ChatTab[]>(CHAT_TABS_STORAGE_KEY, []).map(tab => ({ + // The cache is a placeholder for the menu; message bodies are re-read from + // the server so a stale cache cannot show a conversation that has moved on. + ...tab, + messages: [], + })), + ); + const [activeTabId, setActiveTabId] = useState<string>(() => { + try { + return localStorage.getItem(ACTIVE_TAB_STORAGE_KEY) ?? ''; + } catch { + return ''; + } + }); + const [threadsLoaded, setThreadsLoaded] = useState(false); + const [error, setError] = useState<string | undefined>(undefined); + + const [inputValue, setInputValue] = useState(''); + const [activeRunsByTab, setActiveRunsByTab] = useState< + Record<string, ActiveChatRun> + >({}); + const [quickPrompts, setQuickPrompts] = useState<string[]>([]); + const [messageFeedback, setMessageFeedback] = useState< + Record<string, 'like' | 'dislike'> + >({}); + const [includePageContext, setIncludePageContext] = useState(true); + /** + * The assistant message whose run has only just ended. + * + * Its thought process stays open, because collapsing it the instant the answer + * lands moves everything below it — the answer the user is mid-sentence through + * jumps up the panel. Older messages start closed. + */ + const [justCompletedId, setJustCompletedId] = useState<string | undefined>(); + const [agents, setAgents] = useState<AiAgent[]>([DEFAULT_CHAT_AGENT]); + const [selectedAgent, setSelectedAgent] = useState<string>(() => + loadStoredAgentKey(AGENT_STORAGE_KEY), + ); + + const [messageHistory, setMessageHistory] = useState<string[]>(() => + readJson<string[]>(HISTORY_STORAGE_KEY, []), + ); + const [historyIndex, setHistoryIndex] = useState(-1); + const [currentDraft, setCurrentDraft] = useState(''); + + const messagesEndRef = useRef<HTMLDivElement>(null); + const inputRef = useRef<TextAreaRef>(null); + + /** + * The run map and the conversation list are also held in refs, and the refs are + * the authority. + * + * The send loop has to ask "is this still my run?" between awaits, and it cannot + * ask React: a run that starts and fails inside one batch never causes a render, + * so a ref synced at render time would still be empty and the loop would discard + * its own result as stale. Writing the ref at the point of mutation removes that + * window. The callbacks read the refs rather than the state so their identities + * do not churn on every streamed frame, which would restart effects mid-run. + */ + const activeRunsByTabRef = useRef<Record<string, ActiveChatRun>>({}); + const chatTabsRef = useRef<ChatTab[]>(chatTabs); + const activeTabIdRef = useRef(activeTabId); + activeTabIdRef.current = activeTabId; + + const updateRuns = useCallback( + ( + updater: ( + previous: Record<string, ActiveChatRun>, + ) => Record<string, ActiveChatRun>, + ) => { + activeRunsByTabRef.current = updater(activeRunsByTabRef.current); + setActiveRunsByTab(activeRunsByTabRef.current); + }, + [], + ); + + const updateTabs = useCallback( + (updater: (previous: ChatTab[]) => ChatTab[]) => { + chatTabsRef.current = updater(chatTabsRef.current); + setChatTabs(chatTabsRef.current); + }, + [], + ); + + /** Resolved when the user answers a checkpoint; see `streamRun`. */ + const checkpointGateRef = useRef<{ resolve: () => void } | null>(null); Review Comment: Runs are tracked per tab, but this single checkpoint resolver is shared by every stream. If two tabs reach checkpoints, continuing one can resolve the other run and leave the selected run blocked. Could the gate be keyed by tab/run like `activeRunsByTab`? ########## superset-frontend/src/features/ai/components/ChatTabsMenu.tsx: ########## @@ -0,0 +1,356 @@ +/** + * 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. + */ + +/** + * @fileoverview The conversation list. + * + * Conversations live behind one menu rather than a tab strip: the panel is narrow + * enough in floating mode that a strip would truncate every name, and the list + * doubles as the history of past conversations, which a strip cannot be. + */ + +import { useCallback, useState } from 'react'; +import type { MouseEvent as ReactMouseEvent } from 'react'; +import { styled } from '@apache-superset/core/theme'; +import { t } from '@apache-superset/core/translation'; +import { Button, Dropdown, Popconfirm } from '@superset-ui/core/components'; +import { Icons } from '@superset-ui/core/components/Icons'; +import type { ChatTab } from '../types'; + +const MenuContainer = styled.div` + background: ${({ theme }) => theme.colorBgElevated}; + border-radius: ${({ theme }) => theme.borderRadius}px; + box-shadow: ${({ theme }) => theme.boxShadowSecondary}; + min-width: ${({ theme }) => theme.sizeUnit * 65}px; + max-height: ${({ theme }) => theme.sizeUnit * 100}px; + overflow-y: auto; + border: 1px solid ${({ theme }) => theme.colorBorderSecondary}; +`; + +const MenuHeader = styled.div` + padding: ${({ theme }) => theme.sizeUnit * 3}px + ${({ theme }) => theme.sizeUnit * 4}px; + border-bottom: 1px solid ${({ theme }) => theme.colorBorderSecondary}; + font-weight: ${({ theme }) => theme.fontWeightStrong}; + font-size: ${({ theme }) => theme.fontSizeSM}px; + color: ${({ theme }) => theme.colorTextSecondary}; + text-transform: uppercase; + letter-spacing: 0.5px; +`; + +const NewChatButton = styled.button` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.sizeUnit * 2}px; + width: 100%; + padding: ${({ theme }) => theme.sizeUnit * 2.5}px + ${({ theme }) => theme.sizeUnit * 4}px; + cursor: pointer; + color: ${({ theme }) => theme.colorPrimary}; + font-weight: ${({ theme }) => theme.fontWeightStrong}; + background: none; + border: none; + text-align: left; + transition: background ${({ theme }) => theme.motionDurationMid}; + + &:hover { + background: ${({ theme }) => theme.colorFillTertiary}; + } +`; + +const TabItem = styled.div<{ isActive: boolean }>` + display: flex; + align-items: center; + justify-content: space-between; + padding: ${({ theme }) => theme.sizeUnit * 2.5}px + ${({ theme }) => theme.sizeUnit * 4}px; + cursor: pointer; + background: ${({ theme, isActive }) => + isActive ? theme.colorFillSecondary : 'transparent'}; + border-left: 3px solid + ${({ theme, isActive }) => (isActive ? theme.colorPrimary : 'transparent')}; + transition: background ${({ theme }) => theme.motionDurationMid}; + + &:hover { + background: ${({ theme }) => theme.colorFillTertiary}; + + .action-btn { + opacity: 1; + } + } +`; + +const TabInfo = styled.div` + display: flex; + align-items: center; + gap: ${({ theme }) => theme.sizeUnit * 2}px; + flex: 1; + overflow: hidden; +`; + +const TabName = styled.span` + font-size: ${({ theme }) => theme.fontSize}px; + color: ${({ theme }) => theme.colorText}; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: ${({ theme }) => theme.sizeUnit * 35}px; +`; + +const TabNameInput = styled.input` + width: 100%; + max-width: ${({ theme }) => theme.sizeUnit * 40}px; + font-size: ${({ theme }) => theme.fontSize}px; + color: ${({ theme }) => theme.colorText}; + background: ${({ theme }) => theme.colorBgContainer}; + border: 1px solid ${({ theme }) => theme.colorBorder}; + border-radius: ${({ theme }) => theme.borderRadius}px; + padding: 2px ${({ theme }) => theme.sizeUnit * 1.5}px; +`; + +const TabTimestamp = styled.span` + font-size: ${({ theme }) => theme.fontSizeSM}px; + color: ${({ theme }) => theme.colorTextQuaternary}; + white-space: nowrap; + flex-shrink: 0; +`; + +const ActionButtons = styled.div` + display: flex; + align-items: center; + gap: 2px; +`; + +const ActionButton = styled.button` + background: none; + border: none; + padding: ${({ theme }) => theme.sizeUnit}px; + cursor: pointer; + color: ${({ theme }) => theme.colorTextSecondary}; + opacity: 0; + transition: all ${({ theme }) => theme.motionDurationMid}; + display: flex; + align-items: center; + justify-content: center; + border-radius: ${({ theme }) => theme.borderRadius}px; + + &:hover, + &:focus-visible { + opacity: 1; + color: ${({ theme }) => theme.colorError}; + background: ${({ theme }) => theme.colorErrorBg}; + } +`; + +const Divider = styled.div` + height: 1px; + background: ${({ theme }) => theme.colorBorderSecondary}; + margin: ${({ theme }) => theme.sizeUnit}px 0; +`; + +const EmptyState = styled.div` + padding: ${({ theme }) => theme.sizeUnit * 5}px + ${({ theme }) => theme.sizeUnit * 4}px; + text-align: center; + color: ${({ theme }) => theme.colorTextSecondary}; + font-size: ${({ theme }) => theme.fontSizeSM}px; +`; + +const MINUTE_SECONDS = 60; +const HOUR_MINUTES = 60; +const DAY_HOURS = 24; +const WEEK_DAYS = 7; + +export const formatRelativeTime = (timestamp: number): string => { + const seconds = Math.floor((Date.now() - timestamp) / 1000); + if (seconds < MINUTE_SECONDS) { + return t('just now'); + } + const minutes = Math.floor(seconds / MINUTE_SECONDS); + if (minutes < HOUR_MINUTES) { + return t('%sm', String(minutes)); + } + const hours = Math.floor(minutes / HOUR_MINUTES); + if (hours < DAY_HOURS) { + return t('%sh', String(hours)); + } + const days = Math.floor(hours / DAY_HOURS); + if (days < WEEK_DAYS) { + return t('%sd', String(days)); + } + return new Date(timestamp).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + }); +}; + +interface ChatTabsMenuProps { + tabs: ChatTab[]; + activeTabId: string; + onSelectTab: (tabId: string) => void; + onNewChat: () => void; + onDeleteTab: (tabId: string) => void; + onRenameTab: (tabId: string, name: string) => void; +} + +export const ChatTabsMenu = ({ + tabs, + activeTabId, + onSelectTab, + onNewChat, + onDeleteTab, + onRenameTab, +}: ChatTabsMenuProps) => { + const [editingTabId, setEditingTabId] = useState<string | null>(null); + const [editingName, setEditingName] = useState(''); + + const startEditing = useCallback((event: ReactMouseEvent, tab: ChatTab) => { + event.stopPropagation(); + setEditingTabId(tab.id); + setEditingName(tab.name); + }, []); + + const cancelEditing = useCallback(() => { + setEditingTabId(null); + setEditingName(''); + }, []); + + const commitRename = useCallback( + (tabId: string) => { + const trimmedName = editingName.trim(); + if (trimmedName) { + onRenameTab(tabId, trimmedName); + } + cancelEditing(); + }, + [cancelEditing, editingName, onRenameTab], + ); + + const menuContent = ( + <MenuContainer data-test="chat-tabs-menu"> + <MenuHeader>{t('Conversations')}</MenuHeader> + <NewChatButton type="button" onClick={onNewChat}> + <Icons.PlusOutlined iconSize="s" /> + <span>{t('New Chat')}</span> + </NewChatButton> + <Divider /> + {tabs.length === 0 ? ( + <EmptyState>{t('No conversations yet')}</EmptyState> + ) : ( + tabs.map(tab => ( + <TabItem + key={tab.id} + isActive={tab.id === activeTabId} + onClick={() => onSelectTab(tab.id)} + > + <TabInfo> + <Icons.MessageOutlined iconSize="s" /> + {editingTabId === tab.id ? ( + <TabNameInput + autoFocus + value={editingName} + onChange={event => setEditingName(event.target.value)} + onClick={event => event.stopPropagation()} + onBlur={() => commitRename(tab.id)} + onKeyDown={event => { + event.stopPropagation(); + if (event.key === 'Enter') { + commitRename(tab.id); + } else if (event.key === 'Escape') { + cancelEditing(); + } + }} + aria-label={t('Conversation name')} + /> + ) : ( + <TabName>{tab.name}</TabName> + )} + {tab.updatedAt !== undefined && ( + <TabTimestamp>{formatRelativeTime(tab.updatedAt)}</TabTimestamp> + )} + </TabInfo> + <ActionButtons> + <ActionButton + type="button" + className="action-btn" + onClick={event => startEditing(event, tab)} + title={t('Rename conversation')} + aria-label={t('Rename conversation')} + > + <Icons.EditOutlined iconSize="s" /> + </ActionButton> + {/* A conversation with messages is confirmed before deletion; an + empty one is discarded without a prompt. */} + {tab.messages.length > 0 ? ( Review Comment: Thread-list tabs are deliberately created with `messages: []` until first selection, so deleting an unopened conversation bypasses this confirmation even when it has persisted messages. Could the list retain/use the server message count for this decision? -- This is an automated message from the Apache Git Service. To respond to the message, please log on to GitHub and use the URL above to go to the specific comment. To unsubscribe, e-mail: [email protected] For queries about this service, please contact Infrastructure at: [email protected] --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
