Line data Source code
1 : import * as React from 'react';
2 : import * as core from '@ankoh/dashql-core';
3 :
4 : import type { Extension } from '@codemirror/state';
5 :
6 : import { buildCatalogNameIndex, CatalogNameIndex, EMPTY_CATALOG_NAME_INDEX } from './catalog_name_index.js';
7 : import { naturalLanguageAssist } from './nl_identifier_plugin.js';
8 : import { assessSql, ComposerInputMode, decideMode } from './input_mode.js';
9 : import { useLogger } from '../platform/logger/logger_provider.js';
10 :
11 1 : const LOG_CTX = 'composer_input_mode';
12 :
13 : /// The input mode of a composer draft
14 : export interface ComposerInputModeState {
15 : /// The mode of the draft, always SQL while the agent is unavailable
16 : mode: ComposerInputMode;
17 : /// The editor assistance of the mode, undefined for the default SQL assistance
18 : assist: Extension | undefined;
19 : /// Called with the draft text whenever it changes, a cleared draft starts over in natural language
20 : onTextChange: (text: string) => void;
21 : /// Called with every editor update of the draft session, re-evaluates the mode
22 : onEditorUpdate: (update: core.buffers.editor.EditorUpdateT) => void;
23 : /// The user picked a mode, it sticks until the draft is cleared
24 : pick: (mode: ComposerInputMode) => void;
25 : /// Decide the mode of the draft that is being sent
26 : resolveSendMode: () => ComposerInputMode;
27 : }
28 :
29 : /// Tell SQL from natural language in a composer draft.
30 : ///
31 : /// The draft starts in natural language and switches to SQL as soon as it is convincingly SQL,
32 : /// until the user picks a mode explicitly. Natural language highlights and completes the names of the catalog,
33 : /// and highlights the references to the session scripts.
34 1 : export function useComposerInputMode(catalog: core.DashQLCatalog, aiAvailable: boolean, scriptNames: readonly string[]): ComposerInputModeState {
35 1 : const logger = useLogger();
36 :
37 : // The input mode follows the draft until the user picks one.
38 : // The ref holds the latest mode for the editor callbacks that decide the next one.
39 1 : const [mode, setModeState] = React.useState(ComposerInputMode.Ai);
40 1 : const modeRef = React.useRef(mode);
41 1 : const modeOverridden = React.useRef(false);
42 1 : const setMode = React.useCallback((next: ComposerInputMode, reason: string, details: Record<string, string> = {}) => {
43 1 : if (modeRef.current === next) return;
44 1 : logger.debug('Switched the composer input mode', { mode: ComposerInputMode[next], reason, ...details }, LOG_CTX);
45 1 : modeRef.current = next;
46 1 : setModeState(next);
47 1 : }, [logger]);
48 1 : const onTextChange = React.useCallback((text: string) => {
49 1 : if (text.trim().length === 0) {
50 1 : setMode(ComposerInputMode.Ai, 'cleared');
51 1 : modeOverridden.current = false;
52 1 : }
53 1 : }, [setMode]);
54 1 : const pick = React.useCallback((next: ComposerInputMode) => {
55 1 : setMode(next, 'picked');
56 1 : modeOverridden.current = true;
57 1 : }, [setMode]);
58 :
59 : // The catalog names that are highlighted and completed in natural language.
60 : // Rebuilt whenever the revision of the catalog moves, null forces a rebuild.
61 1 : const [identifierIndex, setIdentifierIndex] = React.useState<CatalogNameIndex>(EMPTY_CATALOG_NAME_INDEX);
62 1 : const indexedRevision = React.useRef<bigint | null>(null);
63 1 : const refreshIdentifierIndex = React.useCallback((revision: bigint | null) => {
64 1 : if (revision != null && revision === indexedRevision.current) return;
65 1 : indexedRevision.current = revision;
66 1 : const index = buildCatalogNameIndex(catalog);
67 1 : logger.debug('Built the catalog name index of the composer', {
68 1 : names: String(index.keys.length),
69 1 : catalogRevision: revision?.toString() ?? null,
70 1 : }, LOG_CTX);
71 1 : setIdentifierIndex(index);
72 1 : }, [catalog, logger]);
73 1 : React.useEffect(() => {
74 1 : if (aiAvailable) refreshIdentifierIndex(null);
75 1 : }, [aiAvailable, refreshIdentifierIndex]);
76 :
77 : // Every edit of the draft re-evaluates the mode.
78 : // The token at the cursor is still being typed and does not count yet.
79 1 : const lastUpdate = React.useRef<core.buffers.editor.EditorUpdateT | null>(null);
80 1 : const latest = React.useRef({ aiAvailable, refreshIdentifierIndex });
81 1 : latest.current = { aiAvailable, refreshIdentifierIndex };
82 1 : const onEditorUpdate = React.useCallback((update: core.buffers.editor.EditorUpdateT) => {
83 1 : lastUpdate.current = update;
84 1 : if (!latest.current.aiAvailable) return;
85 1 : latest.current.refreshIdentifierIndex(update.catalogRevision);
86 1 : if (!update.textChanged || modeOverridden.current) return;
87 1 : const cursor = update.primarySelection != null ? Number(update.primarySelection.head) : null;
88 1 : const evidence = assessSql(update, cursor);
89 1 : setMode(decideMode(modeRef.current, evidence), 'detected', {
90 1 : validTokens: String(evidence.validTokens),
91 1 : tailTokens: String(evidence.tailTokens),
92 1 : });
93 1 : }, [setMode]);
94 :
95 : // The draft is final now, every token counts
96 1 : const resolveSendMode = React.useCallback(() => {
97 1 : if (!latest.current.aiAvailable) return ComposerInputMode.Sql;
98 1 : if (modeOverridden.current || lastUpdate.current == null) return modeRef.current;
99 1 : const next = decideMode(modeRef.current, assessSql(lastUpdate.current, null), true);
100 1 : setMode(next, 'sent');
101 1 : return next;
102 1 : }, [setMode]);
103 :
104 1 : const effectiveMode = aiAvailable ? mode : ComposerInputMode.Sql;
105 1 : const assist = React.useMemo(
106 1 : () => effectiveMode === ComposerInputMode.Ai ? naturalLanguageAssist(identifierIndex, scriptNames) : undefined,
107 1 : [effectiveMode, identifierIndex, scriptNames],
108 : );
109 1 : return { mode: effectiveMode, assist, onTextChange, onEditorUpdate, pick, resolveSendMode };
110 1 : }
|