Line data Source code
1 : import * as React from 'react';
2 : import * as core from '@ankoh/dashql-core';
3 : import * as styles from './session_composer.module.css';
4 :
5 : import type { EditorView } from '@codemirror/view';
6 :
7 : import { ButtonSize, ButtonVariant, IconButton } from '../../ui/foundations/button.js';
8 : import { SegmentedControl, SegmentedControlSize } from '../../ui/foundations/segmented_control.js';
9 : import { CodeIcon, PaperAirplaneIcon, SymbolIcon } from '../../ui/foundations/symbol_icon.js';
10 : import { PromptEditor } from '../editor/prompt_editor.js';
11 : import { DashQLCompletionAbortEffect } from '../editor/dashql_processor.js';
12 : import { insertMentionAtCursor } from '../editor/editor_focus.js';
13 : import { ComposerInputMode } from '../../agent/input_mode.js';
14 : import { useComposerInputMode } from '../../agent/use_composer_input_mode.js';
15 : import { findScriptReferences, renderScriptReference, SCRIPT_REFERENCE_PREFIX } from '../../agent/script_references.js';
16 : import { SnippetsOverlay } from '../snippets/snippets_overlay.js';
17 : import { AgentPromptPanel, isAgentWorking, type ComposerAgent } from '../../agent/agent_prompt_panel.js';
18 : import { ExecuteConnectionGuard, SessionConnectionAttentionContext } from '../session_connection_attention.js';
19 : import type { ConnectorType } from '../../connections/connector_info.js';
20 : import type { SnippetRegistry } from '../snippets/snippet_registry.js';
21 :
22 1 : const SparklesFillIcon = SymbolIcon('sparkles_fill_16');
23 :
24 : /// A composer without session scripts
25 1 : const NO_SCRIPT_NAMES: readonly string[] = [];
26 :
27 : /// Where the composer is shown
28 1 : export enum SessionComposerVariant {
29 : /// Docked below the feed of a session with scripts
30 1 : Docked,
31 : /// Centered on the landing view of an empty session
32 1 : Hero,
33 1 : }
34 :
35 : export interface SessionComposerProps {
36 : /// The DashQL instance that creates the draft session
37 : instance: core.DashQL;
38 : /// The connection catalog the draft is analyzed and completed against
39 : catalog: core.DashQLCatalog;
40 : /// Where the composer is shown
41 : variant: SessionComposerVariant;
42 : /// The keyword case for completions
43 : keywordCase: core.buffers.formatting.KeywordCase;
44 : /// Can the session ask the agent? Without agent access, the composer only takes SQL.
45 : aiAvailable: boolean;
46 : /// The agent of a session with agent access, shown on top of the prompt
47 : agent?: ComposerAgent | null;
48 : /// Called with the draft and its input mode when the user sends it, the draft is cleared before
49 : onSend: (text: string, mode: ComposerInputMode) => void;
50 : /// Called with whether the draft is blank, once on mount and whenever that changes
51 : onDraftEmptyChange?: (empty: boolean) => void;
52 : /// Receives the editor view once it is mounted
53 : setView?: (view: EditorView | null) => void;
54 : /// Receives the send function of the composer, e.g. for global key bindings
55 : submitRef?: React.RefObject<(() => void) | null>;
56 : /// The display names of the session scripts that a question can reference
57 : scriptNames?: readonly string[];
58 : /// Receives the function that references a session script in a question, by its display name
59 : targetScriptRef?: React.RefObject<((name: string) => void) | null>;
60 : /// The results of the session scripts, the table parameters of the draft read them
61 : tableParameters?: core.DashQLTableParameters | null;
62 : /// Receives the function that renames the references to a session script in the draft, by display names
63 : renameScriptRef?: React.RefObject<((from: string, to: string) => void) | null>;
64 : /// Picks the connection that the draft runs on
65 : connectionPicker?: React.ReactNode;
66 : /// The session of the draft, set while the connection of the draft is not connected.
67 : /// A SQL draft is not sent then, the connections of the session ask for attention instead.
68 : offlineSessionId?: string | null;
69 : /// The connector of the draft, the composer offers the snippets that run on it
70 : snippetConnector?: ConnectorType;
71 : /// The snippets to browse, the bundled ones by default
72 : snippetRegistry?: SnippetRegistry;
73 : }
74 :
75 : /// A change of the text of a draft
76 : type DraftChange = { from: number; to: number; insert: string };
77 :
78 : /// The changes that rename the references to a script in a SQL draft: the table parameters of its analysis that find
79 : /// the script among the names before the rename, like the planner does. String literals and comments keep their text.
80 : /// `names` are the display names of the session scripts, before or after the rename.
81 0 : export function renameSqlDraftReferences(session: core.DashQLScriptSession, names: readonly string[], from: string, to: string, instance: core.DashQL): DraftChange[] {
82 0 : const before = names.map(name => name === to ? from : name);
83 0 : if (!before.includes(from)) before.push(from);
84 0 : const renamed = before.indexOf(from);
85 0 : const insert = `${SCRIPT_REFERENCE_PREFIX}${instance.quoteIdentifier(to)}`;
86 0 : return session.findScriptReferences(before)
87 0 : .filter(reference => reference.script === renamed)
88 0 : .map(reference => ({ from: reference.from, to: reference.to, insert }));
89 0 : }
90 :
91 : /// The changes that rename the references to a script in a question, `:from` becomes `:to`
92 0 : export function renameQuestionReferences(text: string, from: string, to: string): DraftChange[] {
93 0 : return findScriptReferences([from], text).map(reference => ({ from: reference.from, to: reference.to, insert: renderScriptReference(to) }));
94 0 : }
95 :
96 : /// Takes the draft out of a composer editor and clears it, returns null when the draft is blank
97 1 : export function takeComposerDraft(view: EditorView): string | null {
98 1 : const text = view.state.doc.toString();
99 1 : if (text.trim().length === 0) {
100 0 : return null;
101 : }
102 : // Drop an open completion together with the text it points into
103 1 : view.dispatch({
104 1 : changes: { from: 0, to: view.state.doc.length, insert: '' },
105 1 : effects: DashQLCompletionAbortEffect.of(null),
106 1 : });
107 1 : return text;
108 1 : }
109 :
110 : /// A chat-like input that turns its draft into a new session script.
111 : ///
112 : /// The draft is either SQL or a question in natural language that the agent answers in SQL,
113 : /// see useComposerInputMode.
114 : ///
115 : /// The draft lives in its own script session on the connection catalog. It provides completion and tells SQL
116 : /// from natural language, but it is not a session script: it is neither persisted nor loaded into the catalog.
117 1 : export const SessionComposer: React.FC<SessionComposerProps> = (props) => {
118 : // The draft session is created while rendering: the editor binds a new session in a layout effect,
119 : // before the effect below destroys the previous one.
120 1 : const scriptSession = React.useMemo(() => props.instance.createScriptSession(props.catalog), [props.instance, props.catalog]);
121 1 : React.useEffect(() => () => scriptSession.destroy(), [scriptSession]);
122 : // The draft reads the results of the session scripts, its next analysis completes their names after `:`.
123 : // Attaching the current results is cheap, the results change with any session script.
124 1 : React.useEffect(() => {
125 1 : props.tableParameters?.attach(scriptSession);
126 1 : });
127 :
128 1 : const [view, setView] = React.useState<EditorView | null>(null);
129 1 : const [empty, setEmpty] = React.useState(true);
130 1 : const handleView = React.useCallback((next: EditorView | null) => {
131 1 : setView(next);
132 1 : props.setView?.(next);
133 1 : }, [props.setView]);
134 :
135 : // The draft is SQL or natural language
136 1 : const scriptNames = props.scriptNames ?? NO_SCRIPT_NAMES;
137 1 : const input = useComposerInputMode(props.catalog, props.aiAvailable, scriptNames);
138 1 : const handleTextChange = React.useCallback((text: string) => {
139 1 : setEmpty(text.trim().length === 0);
140 1 : input.onTextChange(text);
141 1 : }, [input.onTextChange]);
142 1 : React.useEffect(() => props.onDraftEmptyChange?.(empty), [empty, props.onDraftEmptyChange]);
143 1 : const handleModeChange = React.useCallback((index: number) => {
144 1 : input.pick(index === 0 ? ComposerInputMode.Sql : ComposerInputMode.Ai);
145 1 : }, [input.pick]);
146 :
147 : // The connection of a SQL draft has to be connected first, the draft stays until it is
148 1 : const connectionAttention = React.useContext(SessionConnectionAttentionContext);
149 1 : const offlineSessionId = props.offlineSessionId ?? null;
150 1 : const send = React.useCallback(() => {
151 1 : if (view == null) return;
152 1 : const mode = input.resolveSendMode();
153 1 : if (mode === ComposerInputMode.Sql && offlineSessionId != null) {
154 1 : connectionAttention?.request(offlineSessionId);
155 1 : return;
156 1 : }
157 1 : const text = takeComposerDraft(view);
158 1 : if (text != null) {
159 1 : props.onSend(text, mode);
160 : }
161 1 : }, [props.onSend, view, input.resolveSendMode, offlineSessionId, connectionAttention]);
162 : // A snippet replaces the draft right away, there is no script yet whose text could be compared
163 1 : const applySnippet = React.useCallback((text: string) => {
164 1 : if (view == null) return;
165 1 : view.dispatch({
166 1 : changes: { from: 0, to: view.state.doc.length, insert: text },
167 1 : selection: { anchor: text.length },
168 1 : effects: DashQLCompletionAbortEffect.of(null),
169 1 : });
170 1 : view.focus();
171 1 : }, [view]);
172 1 : const handleSendClick = React.useCallback(() => {
173 1 : send();
174 1 : view?.focus();
175 1 : }, [send, view]);
176 1 : if (props.submitRef) {
177 1 : props.submitRef.current = send;
178 : }
179 : // A script reference turns the draft into a question if the agent is available, a script that the question
180 : // references already is not repeated. Without the agent, the reference goes into the SQL of the draft.
181 1 : const targetScript = React.useCallback((name: string) => {
182 1 : if (view == null) return;
183 1 : if (props.aiAvailable) {
184 1 : input.pick(ComposerInputMode.Ai);
185 1 : if (findScriptReferences([name], view.state.doc.toString()).length === 0) {
186 1 : insertMentionAtCursor(view, renderScriptReference(name));
187 : }
188 1 : } else {
189 0 : insertMentionAtCursor(view, `:${props.instance.quoteIdentifier(name)}`);
190 : }
191 1 : view.focus();
192 1 : }, [view, props.aiAvailable, props.instance, input.pick]);
193 1 : if (props.targetScriptRef) {
194 1 : props.targetScriptRef.current = targetScript;
195 : }
196 : // The references of the draft follow a renamed script. A question has no syntax tree, SQL is renamed through its
197 : // analysis.
198 1 : const renameScript = React.useCallback((from: string, to: string) => {
199 0 : if (view == null) return;
200 0 : const changes = input.mode === ComposerInputMode.Ai
201 0 : ? renameQuestionReferences(view.state.doc.toString(), from, to)
202 0 : : renameSqlDraftReferences(scriptSession, scriptNames, from, to, props.instance);
203 0 : if (changes.length > 0) {
204 0 : view.dispatch({ changes, effects: DashQLCompletionAbortEffect.of(null), userEvent: 'input' });
205 : }
206 1 : }, [view, input.mode, scriptSession, scriptNames, props.instance]);
207 1 : if (props.renameScriptRef) {
208 1 : props.renameScriptRef.current = renameScript;
209 : }
210 :
211 : // The landing view is all about the prompt, focus it right away
212 1 : const hero = props.variant === SessionComposerVariant.Hero;
213 1 : React.useEffect(() => {
214 1 : if (hero) view?.focus();
215 1 : }, [view]);
216 :
217 1 : const naturalLanguage = input.mode === ComposerInputMode.Ai;
218 1 : const blockedSessionId = naturalLanguage ? null : offlineSessionId;
219 1 : const blocked = blockedSessionId != null;
220 : // A blocked button stays clickable, it pulses the connections that are not connected
221 1 : const sendButton = (
222 1 : <IconButton
223 1 : variant={hero && !blocked ? ButtonVariant.Primary : ButtonVariant.Default}
224 1 : size={ButtonSize.Small}
225 1 : aria-label={naturalLanguage ? 'Ask the agent' : blocked ? 'Connect to run' : 'Run query'}
226 1 : data-testid="composer-send"
227 1 : data-connect={blocked ? '' : undefined}
228 1 : disabled={empty && !blocked}
229 1 : onClick={handleSendClick}
230 : >
231 1 : <PaperAirplaneIcon />
232 1 : </IconButton>
233 : );
234 1 : const agent = props.agent ?? null;
235 1 : const placeholder = naturalLanguage
236 1 : ? 'Ask a question about your data, Shift+Enter for a new line'
237 1 : : (hero ? 'select * from ...' : 'Write a query, press Enter to run it and Shift+Enter for a new line');
238 1 : return (
239 1 : <div
240 1 : className={styles.composer}
241 1 : data-variant={hero ? 'hero' : 'docked'}
242 1 : data-agent={agent == null ? undefined : (isAgentWorking(agent) ? 'working' : 'ready')}
243 : >
244 1 : {agent != null && <AgentPromptPanel agent={agent} />}
245 1 : <PromptEditor
246 1 : className={styles.composer_editor}
247 1 : scriptSession={scriptSession}
248 1 : placeholder={placeholder}
249 1 : keywordCase={props.keywordCase}
250 1 : assist={input.assist}
251 1 : onSubmit={send}
252 1 : onTextChange={handleTextChange}
253 1 : onEditorUpdate={input.onEditorUpdate}
254 1 : setView={handleView}
255 1 : />
256 1 : <div className={styles.composer_action_bar}>
257 1 : <SegmentedControl aria-label="Input mode" size={SegmentedControlSize.Small} onChange={handleModeChange}>
258 1 : <SegmentedControl.Button leadingVisual={CodeIcon} selected={!naturalLanguage} data-testid="composer-mode" data-mode="sql">
259 : SQL
260 1 : </SegmentedControl.Button>
261 1 : <SegmentedControl.Button
262 1 : leadingVisual={SparklesFillIcon}
263 1 : selected={naturalLanguage}
264 1 : disabled={!props.aiAvailable}
265 1 : data-testid="composer-mode"
266 1 : data-mode="ai"
267 1 : title={props.aiAvailable ? undefined : 'Start an agent session in the header and connect the agent server to ask questions'}
268 1 : >
269 : AI
270 1 : </SegmentedControl.Button>
271 1 : </SegmentedControl>
272 1 : <div className={styles.composer_trailing_actions}>
273 1 : {props.connectionPicker}
274 1 : {props.snippetConnector != null && (
275 1 : <SnippetsOverlay
276 1 : scriptName="the prompt"
277 1 : connectorType={props.snippetConnector}
278 1 : disabled={view == null}
279 1 : onApply={applySnippet}
280 1 : registry={props.snippetRegistry}
281 1 : testId="composer-snippets"
282 1 : />
283 : )}
284 1 : {blockedSessionId != null
285 1 : ? <ExecuteConnectionGuard sessionId={blockedSessionId} blocked>{sendButton}</ExecuteConnectionGuard>
286 1 : : sendButton}
287 1 : </div>
288 1 : </div>
289 1 : </div>
290 : );
291 1 : };
|