Compare commits
75 Commits
testtestte
...
transforms
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e99c68e9cc | ||
|
|
fa3911cbff | ||
|
|
d85ea68a4f | ||
|
|
8545180dc5 | ||
|
|
2071b499ab | ||
|
|
42f7f91247 | ||
|
|
c7ab945216 | ||
|
|
11e7f8f792 | ||
|
|
06187e258c | ||
|
|
1521f87c35 | ||
|
|
9746d26262 | ||
|
|
bde75ec8cb | ||
|
|
c386f5dc06 | ||
|
|
db9915dc19 | ||
|
|
96c5218a0e | ||
|
|
5e512b1870 | ||
|
|
d093af8894 | ||
|
|
7e54498224 | ||
|
|
83ecd08c47 | ||
|
|
bb30961bab | ||
|
|
0176fb79c3 | ||
|
|
fc63da673f | ||
|
|
83212f5a87 | ||
|
|
d47c329ed7 | ||
|
|
4c3aaac096 | ||
|
|
78e3516d32 | ||
|
|
4b483e8ac4 | ||
|
|
025f903439 | ||
|
|
47f82c8a77 | ||
|
|
9599fc0116 | ||
|
|
697426d297 | ||
|
|
942643a1a8 | ||
|
|
21870b3bdf | ||
|
|
7cf553aee0 | ||
|
|
29b6ee74a8 | ||
|
|
b4b7907cc6 | ||
|
|
18e00b770c | ||
|
|
5fb62d64b7 | ||
|
|
cd507e20a3 | ||
|
|
8c62c354ad | ||
|
|
0ae57508ec | ||
|
|
8fc39832c8 | ||
|
|
20f5fa2761 | ||
|
|
5dce588375 | ||
|
|
e92e9017b9 | ||
|
|
4515a136ff | ||
|
|
874cd510b5 | ||
|
|
7dec13db6c | ||
|
|
a840799f61 | ||
|
|
96b217181c | ||
|
|
c7831044e2 | ||
|
|
c84d7977e8 | ||
|
|
8fb2f8af83 | ||
|
|
45206f30b5 | ||
|
|
b2db725412 | ||
|
|
f089029b69 | ||
|
|
5215a1f04b | ||
|
|
ea7cc08248 | ||
|
|
fc9ed44ef4 | ||
|
|
6b50f2284a | ||
|
|
30b0e76808 | ||
|
|
625d9c357c | ||
|
|
5aed907492 | ||
|
|
b991bc7052 | ||
|
|
add9cea204 | ||
|
|
d27be47233 | ||
|
|
abf38d93bd | ||
|
|
78b7f95c52 | ||
|
|
90a338f8a7 | ||
|
|
4b8bd6e61f | ||
|
|
0934b50409 | ||
|
|
5e095f519b | ||
|
|
ba34ffd9ca | ||
|
|
5cf33d5aef | ||
|
|
b7ee9cb338 |
@@ -1826,6 +1826,7 @@
|
||||
lib
|
||||
lib-be
|
||||
models
|
||||
native-query-snippets
|
||||
permissions
|
||||
premium-features
|
||||
pulse
|
||||
@@ -1841,7 +1842,9 @@
|
||||
util
|
||||
warehouse-schema
|
||||
warehouses
|
||||
enterprise/documents}}
|
||||
enterprise/documents
|
||||
enterprise/transforms
|
||||
enterprise/transforms-python}}
|
||||
|
||||
enterprise/permission-debug
|
||||
{:team "UX West"
|
||||
@@ -2013,6 +2016,7 @@
|
||||
premium-features
|
||||
query-processor
|
||||
request
|
||||
search
|
||||
settings
|
||||
sync
|
||||
task
|
||||
|
||||
@@ -69,7 +69,6 @@
|
||||
"Send a chat message to the LLM via the AI Proxy."
|
||||
[_route-params
|
||||
_query-params
|
||||
|
||||
body :- [:map
|
||||
[:profile_id {:optional true} :string]
|
||||
[:metabot_id {:optional true} :string]
|
||||
|
||||
@@ -54,6 +54,18 @@
|
||||
:api/endpoints)]
|
||||
(str "backend:/api/ee/metabot-tools" url)))
|
||||
|
||||
(defn- query-for-sql-parsing
|
||||
"Given an item in context, return the query if it is a native query or SQL transform that can have table usage parsed
|
||||
from it, otherwise nil."
|
||||
[item]
|
||||
(when-let [query (case (:type item)
|
||||
"transform" (-> item :source :query)
|
||||
"adhoc" (-> item :query)
|
||||
(-> item :query))]
|
||||
(when (and (#{:native "native"} (:type query))
|
||||
(:database query))
|
||||
query)))
|
||||
|
||||
(defn- database-tables-for-context
|
||||
"Get database tables formatted for metabot context. Only includes tables used in the query, formatted for API output.
|
||||
Removes duplicate tables by id while preserving first occurrence order."
|
||||
@@ -70,19 +82,48 @@
|
||||
(log/error e "Error getting database tables for context")
|
||||
[])))
|
||||
|
||||
(defn- python-transform-db-and-table-ids
|
||||
"Returns a map with :database-id and :table-ids, or nil if not a Python transform."
|
||||
[item]
|
||||
(when (and (= (:type item) "transform")
|
||||
(= (get-in item [:source :type]) "python"))
|
||||
(when-let [source-database (get-in item [:source :source-database])]
|
||||
(when-let [source-tables (not-empty (get-in item [:source :source-tables]))]
|
||||
{:database-id source-database
|
||||
:table-ids (vals source-tables)}))))
|
||||
|
||||
(defn- python-transform-tables-for-context
|
||||
"Get tables for Python transform formatted for metabot context."
|
||||
[{:keys [database-id table-ids]}]
|
||||
(try
|
||||
(when (and database-id (seq table-ids))
|
||||
(when-let [tables (not-empty (table-utils/used-tables-from-ids database-id table-ids))]
|
||||
(table-utils/enhanced-database-tables database-id
|
||||
{:priority-tables tables
|
||||
:all-tables-limit (count tables)})))
|
||||
(catch Exception e
|
||||
(log/error e "Error getting Python transform tables for context")
|
||||
[])))
|
||||
|
||||
(defn- enhance-context-with-schema
|
||||
"Enhance context by adding table schema information for native queries"
|
||||
"Enhance context by adding table schema information for native queries, SQL transforms, and Python transforms"
|
||||
[context]
|
||||
(if-let [user-viewing (get context :user_is_viewing)]
|
||||
(let [enhanced-viewing
|
||||
(mapv (fn [item]
|
||||
(if (and (#{:native "native"} (get-in item [:query :type]))
|
||||
(get-in item [:query :database]))
|
||||
(let [tables (database-tables-for-context {:query (:query item)})]
|
||||
(if (seq tables)
|
||||
(assoc item :used_tables tables)
|
||||
item))
|
||||
item))
|
||||
(or
|
||||
;; Handle native queries and SQL transforms
|
||||
(when-let [query (query-for-sql-parsing item)]
|
||||
(when-let [tables (seq (database-tables-for-context {:query query}))]
|
||||
(assoc item :used_tables tables)))
|
||||
|
||||
;; Handle Python transforms
|
||||
(when-let [db-and-table-ids (python-transform-db-and-table-ids item)]
|
||||
(when-let [tables (seq (python-transform-tables-for-context db-and-table-ids))]
|
||||
(assoc item :used_tables tables)))
|
||||
|
||||
;; Unknown item: return unchanged
|
||||
item))
|
||||
user-viewing)]
|
||||
(assoc context :user_is_viewing enhanced-viewing))
|
||||
context))
|
||||
|
||||
@@ -152,6 +152,15 @@
|
||||
(nil? tschema)
|
||||
(similar? uschema tschema))))
|
||||
|
||||
(defn- visible-filter-clause
|
||||
[]
|
||||
(mi/visible-filter-clause :model/Table
|
||||
:id
|
||||
{:user-id api/*current-user-id*
|
||||
:is-superuser? api/*is-superuser?*}
|
||||
{:perms/view-data :unrestricted
|
||||
:perms/create-queries :query-builder-and-native}))
|
||||
|
||||
(defn find-matching-tables
|
||||
"Find tables in the database that are similar to the unrecognized tables using fuzzy matching.
|
||||
|
||||
@@ -175,17 +184,26 @@
|
||||
:db_id database-id
|
||||
:active true
|
||||
:visibility_type nil
|
||||
(cond-> {:where (mi/visible-filter-clause :model/Table
|
||||
:id
|
||||
{:user-id api/*current-user-id*
|
||||
:is-superuser? api/*is-superuser?*}
|
||||
{:perms/view-data :unrestricted
|
||||
:perms/create-queries :query-builder-and-native})
|
||||
(cond-> {:where (visible-filter-clause)
|
||||
:limit 10000}
|
||||
(seq used-ids) (update :where #(if %
|
||||
[:and % [:not-in :id used-ids]]
|
||||
[:not-in :id used-ids]))))))
|
||||
|
||||
(defn used-tables-from-ids
|
||||
"Return table info for `table-ids` in the same shape as [[used-tables]].
|
||||
|
||||
Useful for cases where you don't have a native query (e.g. python transforms), but do have a seq of used table ids."
|
||||
[database-id table-ids]
|
||||
(if-not (seq table-ids)
|
||||
[]
|
||||
(t2/select [:model/Table :id :name :schema]
|
||||
:db_id database-id
|
||||
:id [:in table-ids]
|
||||
:active true
|
||||
:visibility_type nil
|
||||
{:where (visible-filter-clause)})))
|
||||
|
||||
(defn used-tables
|
||||
"Return all tables used in the query, including fuzzy-matched ones.
|
||||
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
[metabase-enterprise.metabot-v3.tools.find-outliers :as metabot-v3.tools.find-outliers]
|
||||
[metabase-enterprise.metabot-v3.tools.generate-insights :as metabot-v3.tools.generate-insights]
|
||||
[metabase-enterprise.metabot-v3.tools.search :as metabot-v3.tools.search]
|
||||
[metabase-enterprise.metabot-v3.tools.snippets :as metabot-v3.tools.snippets]
|
||||
[metabase-enterprise.metabot-v3.tools.transforms :as metabot-v3.tools.transforms]
|
||||
[metabase-enterprise.metabot-v3.util :as metabot-v3.u]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.api.response :as api.response]
|
||||
@@ -619,6 +621,66 @@
|
||||
[:structured_output ::full-table]]
|
||||
[:map [:output :string]]])
|
||||
|
||||
(mr/def ::basic-transform
|
||||
[:map
|
||||
{:decode/tool-api-response #(update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:id :int]
|
||||
[:name :string]
|
||||
[:description {:optional true} [:maybe :string]]
|
||||
[:entity_id {:optional true} [:maybe :string]]
|
||||
[:source [:and
|
||||
::metabot-v3.tools.transforms/transform-source
|
||||
[:map
|
||||
{:decode/tool-api-response #(update-keys % metabot-v3.u/safe->snake_case_en)}]]]])
|
||||
|
||||
(mr/def ::full-transform
|
||||
[:merge
|
||||
::basic-transform
|
||||
[:map {:decode/tool-api-response #(update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:created_at ms/TemporalString]
|
||||
[:updated_at ms/TemporalString]
|
||||
[:target [:and
|
||||
::metabot-v3.tools.transforms/transform-target
|
||||
[:map
|
||||
{:decode/tool-api-response #(update-keys % metabot-v3.u/safe->snake_case_en)}]]]]])
|
||||
|
||||
(mr/def ::get-transforms-result
|
||||
[:or
|
||||
[:map
|
||||
{:decode/tool-api-response #(update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:structured_output [:sequential ::basic-transform]]]
|
||||
[:map [:output :string]]])
|
||||
|
||||
(mr/def ::get-transform-details-arguments
|
||||
[:and
|
||||
[:map
|
||||
[:transform_id :int]]
|
||||
[:map {:encode/tool-api-request
|
||||
#(set/rename-keys % {:transform_id :transform-id})}]])
|
||||
|
||||
(mr/def ::get-transform-details-result
|
||||
[:or
|
||||
[:map {:decode/tool-api-response #(update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:structured_output [:sequential ::full-transform]]]
|
||||
[:map [:output :string]]])
|
||||
|
||||
(mr/def ::get-transform-python-library-details-arguments
|
||||
[:and
|
||||
[:map
|
||||
[:path :string]]
|
||||
[:map {:encode/tool-api-request
|
||||
#(update-keys % metabot-v3.u/safe->kebab-case-en)}]])
|
||||
|
||||
(mr/def ::get-transform-python-library-details-result
|
||||
[:or
|
||||
[:map {:decode/tool-api-response #(update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:structured_output [:map {:decode/tool-api-response #(update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:source :string]
|
||||
[:path :string]
|
||||
[:created_at ms/TemporalString]
|
||||
[:updated_at ms/TemporalString]]]]
|
||||
[:map [:output :string]]])
|
||||
|
||||
(mr/def ::answer-sources-result
|
||||
[:or
|
||||
[:map
|
||||
@@ -628,6 +690,36 @@
|
||||
[:models [:sequential ::full-table]]]]]
|
||||
[:map [:output :string]]])
|
||||
|
||||
(mr/def ::basic-snippet
|
||||
[:map {:decode/tool-api-response #(update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:id :int]
|
||||
[:name :string]
|
||||
[:description {:optional true} [:maybe :string]]])
|
||||
|
||||
(mr/def ::full-snippet
|
||||
[:merge
|
||||
::basic-snippet
|
||||
[:map {:decode/tool-api-response #(update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:content :string]]])
|
||||
|
||||
(mr/def ::get-snippets-result
|
||||
"Schema for SQL snippet list results"
|
||||
[:map
|
||||
{:decode/tool-api-response #(update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:structured_output [:sequential ::basic-snippet]]])
|
||||
|
||||
(mr/def ::get-snippet-details-arguments
|
||||
[:and
|
||||
[:map
|
||||
[:snippet_id :int]]
|
||||
[:map {:encode/tool-api-request
|
||||
#(set/rename-keys % {:snippet_id :snippet-id})}]])
|
||||
|
||||
(mr/def ::get-snippet-details-result
|
||||
"Schema for SQL snippet detail results"
|
||||
[:map {:decode/tool-api-response #(update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:structured_output ::full-snippet]])
|
||||
|
||||
(api.macros/defendpoint :post "/answer-sources" :- [:merge ::answer-sources-result ::tool-request]
|
||||
"Return top level meta information about available information sources."
|
||||
[_route-params
|
||||
@@ -849,6 +941,54 @@
|
||||
(assoc :conversation_id conversation_id))
|
||||
(metabot-v3.context/log :llm.log/be->llm))))
|
||||
|
||||
(api.macros/defendpoint :post "/get-transforms" :- [:merge ::get-transforms-result ::tool-request]
|
||||
"Get a list of all known transforms."
|
||||
[_route-params
|
||||
_query-params
|
||||
{:keys [conversation_id] :as body} :- ::tool-request]
|
||||
(metabot-v3.context/log (assoc body :api :get-transforms) :llm.log/llm->be)
|
||||
(doto (-> (mc/decode ::get-transforms-result
|
||||
(metabot-v3.tools.transforms/get-transforms)
|
||||
(mtx/transformer {:name :tool-api-response}))
|
||||
(assoc :conversation_id conversation_id))
|
||||
(metabot-v3.context/log :llm.log/be->llm)))
|
||||
|
||||
(api.macros/defendpoint :post "/get-transform-details" :- [:merge ::get-transform-details-result ::tool-request]
|
||||
"Get information about a transform."
|
||||
[_route-params
|
||||
_query-params
|
||||
{:keys [arguments conversation_id] :as body} :- [:merge
|
||||
[:map [:arguments ::get-transform-details-arguments]]
|
||||
::tool-request]]
|
||||
(metabot-v3.context/log (assoc body :api :get-transform-details) :llm.log/llm->be)
|
||||
(let [arguments (mc/encode ::get-transform-details-arguments arguments (mtx/transformer {:name :tool-api-request}))
|
||||
transform-id (:transform-id arguments)]
|
||||
(doto (-> (mc/decode ::get-transform-details-result
|
||||
(metabot-v3.tools.transforms/get-transform-details transform-id)
|
||||
(mtx/transformer {:name :tool-api-response}))
|
||||
(assoc :conversation_id conversation_id))
|
||||
(metabot-v3.context/log :llm.log/be->llm))))
|
||||
|
||||
(api.macros/defendpoint :post "/get-transform-python-library-details" :- [:merge
|
||||
::get-transform-python-library-details-result
|
||||
::tool-request]
|
||||
"Get information about a Python library by path."
|
||||
[_route-params
|
||||
_query-params
|
||||
{:keys [arguments conversation_id] :as body} :- [:merge
|
||||
[:map [:arguments
|
||||
::get-transform-python-library-details-arguments]]
|
||||
::tool-request]]
|
||||
(metabot-v3.context/log (assoc body :api :get-transform-python-library-details) :llm.log/llm->be)
|
||||
(let [arguments (mc/encode ::get-transform-python-library-details-arguments
|
||||
arguments (mtx/transformer {:name :tool-api-request}))
|
||||
path (:path arguments)]
|
||||
(doto (-> (mc/decode ::get-transform-python-library-details-result
|
||||
(metabot-v3.tools.transforms/get-transform-python-library-details path)
|
||||
(mtx/transformer {:name :tool-api-response}))
|
||||
(assoc :conversation_id conversation_id))
|
||||
(metabot-v3.context/log :llm.log/be->llm))))
|
||||
|
||||
(api.macros/defendpoint :post "/query-metric" :- [:merge ::filtering-result ::tool-request]
|
||||
"Construct a query from a metric."
|
||||
[_route-params
|
||||
@@ -901,13 +1041,14 @@
|
||||
(mr/def ::search-arguments
|
||||
[:and
|
||||
[:map
|
||||
[:term_queries {:optional true} [:maybe [:sequential :string]]]
|
||||
[:semantic_queries {:optional true} [:maybe [:sequential :string]]]
|
||||
[:entity_types {:optional true} [:maybe [:sequential [:enum "table" "model" "question" "dashboard" "metric" "database"]]]]
|
||||
[:database_id {:optional true} [:maybe :int]]
|
||||
[:created_at {:optional true} [:maybe ms/NonBlankString]]
|
||||
[:last_edited_at {:optional true} [:maybe ms/NonBlankString]]
|
||||
[:limit {:optional true, :default 50} [:and :int [:fn #(<= 1 % 100)]]]]
|
||||
[:term_queries {:optional true} [:maybe [:sequential :string]]]
|
||||
[:semantic_queries {:optional true} [:maybe [:sequential :string]]]
|
||||
[:entity_types {:optional true} [:maybe [:sequential [:enum "table" "model" "question" "dashboard" "metric" "database" "transform"]]]]
|
||||
[:database_id {:optional true} [:maybe :int]]
|
||||
[:created_at {:optional true} [:maybe ms/NonBlankString]]
|
||||
[:last_edited_at {:optional true} [:maybe ms/NonBlankString]]
|
||||
[:search_native_query {:optional true, :default false} [:maybe :boolean]]
|
||||
[:limit {:optional true, :default 50} [:and :int [:fn #(<= 1 % 100)]]]]
|
||||
[:map {:encode/tool-api-request
|
||||
#(update-keys % (comp keyword u/->kebab-case-en))}]])
|
||||
|
||||
@@ -965,6 +1106,35 @@
|
||||
(assoc :conversation_id conversation_id))
|
||||
(metabot-v3.context/log :llm.log/be->llm)))))
|
||||
|
||||
(api.macros/defendpoint :post "/get-snippets" :- [:merge ::get-snippets-result ::tool-request]
|
||||
"Get a list of all known SQL snippets."
|
||||
[_route-params
|
||||
_query-params
|
||||
{:keys [conversation_id] :as body} :- ::tool-request]
|
||||
(metabot-v3.context/log (assoc body :api :get-snippets) :llm.log/llm->be)
|
||||
(doto (-> (mc/decode ::get-snippets-result
|
||||
(metabot-v3.tools.snippets/get-snippets)
|
||||
(mtx/transformer {:name :tool-api-response}))
|
||||
(assoc :conversation_id conversation_id))
|
||||
(metabot-v3.context/log :llm.log/be->llm)))
|
||||
|
||||
(api.macros/defendpoint :post "/get-snippet-details" :- [:merge ::get-snippet-details-result ::tool-request]
|
||||
"Get the content of a single SQL snippet."
|
||||
[_route-params
|
||||
_query-params
|
||||
{:keys [arguments conversation_id] :as body} :- [:merge
|
||||
[:map [:arguments ::get-snippet-details-arguments]]
|
||||
::tool-request]]
|
||||
|
||||
(metabot-v3.context/log (assoc body :api :get-snippet-details) :llm.log/llm->be)
|
||||
(let [arguments (mc/encode ::get-snippet-details-arguments arguments (mtx/transformer {:name :tool-api-request}))
|
||||
snippet-id (:snippet-id arguments)]
|
||||
(doto (-> (mc/decode ::get-snippet-details-result
|
||||
(metabot-v3.tools.snippets/get-snippet-details snippet-id)
|
||||
(mtx/transformer {:name :tool-api-response}))
|
||||
(assoc :conversation_id conversation_id))
|
||||
(metabot-v3.context/log :llm.log/be->llm))))
|
||||
|
||||
(defn- enforce-authentication
|
||||
"Middleware that returns a 401 response if no `ai-session` can be found for `request`."
|
||||
[handler]
|
||||
|
||||
@@ -6,10 +6,11 @@
|
||||
[metabase.api.common :as api]
|
||||
[metabase.permissions.core :as perms]
|
||||
[metabase.search.core :as search]
|
||||
[metabase.util.log :as log]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(def ^:private metabot-search-models
|
||||
#{"table" "dataset" "card" "dashboard" "metric" "database"})
|
||||
#{"table" "dataset" "card" "dashboard" "metric" "database" "transform"})
|
||||
|
||||
(def ^:private search-model-mappings
|
||||
"Maps metabot entity types to search engine model types"
|
||||
@@ -24,18 +25,18 @@
|
||||
[search-model]
|
||||
(get (set/map-invert search-model-mappings) search-model search-model))
|
||||
|
||||
(defn- transform-search-result
|
||||
(defn- postprocess-search-result
|
||||
"Transform a single search result to match the appropriate entity-specific schema."
|
||||
[{:keys [verified moderated_status collection] :as result}]
|
||||
(let [model (:model result)
|
||||
verified? (or (boolean verified) (= moderated_status "verified"))
|
||||
collection-info (select-keys collection [:name :authority_level])
|
||||
common-fields {:id (:id result)
|
||||
:type (search-model->result-type model)
|
||||
:name (:name result)
|
||||
:description (:description result)
|
||||
:updated_at (:updated_at result)
|
||||
:created_at (:created_at result)}]
|
||||
common-fields {:id (:id result)
|
||||
:type (search-model->result-type model)
|
||||
:name (:name result)
|
||||
:description (:description result)
|
||||
:updated_at (:updated_at result)
|
||||
:created_at (:created_at result)}]
|
||||
(case model
|
||||
"database"
|
||||
common-fields
|
||||
@@ -49,14 +50,18 @@
|
||||
|
||||
"dashboard"
|
||||
(merge common-fields
|
||||
{:verified verified?
|
||||
:collection collection-info})
|
||||
{:verified verified?
|
||||
:collection collection-info})
|
||||
|
||||
"transform"
|
||||
(merge common-fields
|
||||
{:database_id (:database_id result)})
|
||||
|
||||
;; Questions, metrics, and datasets
|
||||
(merge common-fields
|
||||
{:database_id (:database_id result)
|
||||
:verified verified?
|
||||
:collection collection-info}))))
|
||||
{:database_id (:database_id result)
|
||||
:verified verified?
|
||||
:collection collection-info}))))
|
||||
|
||||
(defn- search-result-id
|
||||
"Generate a unique identifier for a search result based on its id and model."
|
||||
@@ -98,10 +103,21 @@
|
||||
"Search for data sources (tables, models, cards, dashboards, metrics) in Metabase.
|
||||
Abstracted from the API endpoint logic."
|
||||
[{:keys [term-queries semantic-queries database-id created-at last-edited-at
|
||||
entity-types limit metabot-id]}]
|
||||
entity-types limit metabot-id search-native-query]}]
|
||||
(log/infof "[METABOT-SEARCH] Starting search with params: %s"
|
||||
{:term-queries term-queries
|
||||
:semantic-queries semantic-queries
|
||||
:database-id database-id
|
||||
:created-at created-at
|
||||
:last-edited-at last-edited-at
|
||||
:entity-types entity-types
|
||||
:limit limit
|
||||
:metabot-id metabot-id
|
||||
:search-native-query search-native-query})
|
||||
(let [search-models (if (seq entity-types)
|
||||
(set (distinct (keep entity-type->search-model entity-types)))
|
||||
metabot-search-models)
|
||||
_ (log/infof "[METABOT-SEARCH] Converted entity-types %s to search-models %s" entity-types search-models)
|
||||
all-queries (distinct (concat (or term-queries []) (or semantic-queries [])))
|
||||
metabot (t2/select-one :model/Metabot :entity_id (get-in metabot-v3.config/metabot-config [metabot-id :entity-id] metabot-id))
|
||||
use-verified-content? (if metabot-id
|
||||
@@ -123,13 +139,24 @@
|
||||
:context :metabot
|
||||
:archived false
|
||||
:limit (or limit 50)
|
||||
:offset 0}
|
||||
:offset 0
|
||||
:search-native-query (boolean search-native-query)}
|
||||
(when use-verified-content?
|
||||
{:verified true})))
|
||||
search-results (search/search search-context)]
|
||||
(:data search-results)))
|
||||
_ (log/infof "[METABOT-SEARCH] Search context models for query '%s': %s"
|
||||
query (:models search-context))
|
||||
search-results (search/search search-context)
|
||||
data (:data search-results)
|
||||
result-models (frequencies (map :model data))]
|
||||
(log/infof "[METABOT-SEARCH] Query '%s' returned entity types: %s" query result-models)
|
||||
data))
|
||||
;; Create futures for parallel execution
|
||||
futures (mapv #(future (search-fn %)) all-queries)
|
||||
result-lists (mapv deref futures)
|
||||
fused-results (reciprocal-rank-fusion result-lists)]
|
||||
(map transform-search-result fused-results)))
|
||||
fused-results (reciprocal-rank-fusion result-lists)
|
||||
entity-type-counts (frequencies (map :model fused-results))
|
||||
postprocessed-results (map postprocess-search-result fused-results)]
|
||||
(log/infof "[METABOT-SEARCH] Entity type distribution: %s" entity-type-counts)
|
||||
(log/infof "[METABOT-SEARCH] Fused results sample (first 3): %s"
|
||||
(take 3 (map #(select-keys % [:id :model :name :table_name]) fused-results)))
|
||||
postprocessed-results))
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
(ns metabase-enterprise.metabot-v3.tools.snippets
|
||||
(:require
|
||||
[metabase-enterprise.metabot-v3.tools.util :as metabot-v3.tools.u]
|
||||
[metabase.native-query-snippets.core :as snippets]))
|
||||
|
||||
(defn get-snippets
|
||||
"Lists SQL snippets available to the current user."
|
||||
[]
|
||||
(try
|
||||
{:structured_output
|
||||
(->> (snippets/list-native-query-snippets)
|
||||
(map #(select-keys % [:id :name :description])))}
|
||||
(catch Exception e
|
||||
(metabot-v3.tools.u/handle-agent-error e))))
|
||||
|
||||
(defn get-snippet-details
|
||||
"Retrieve a specific SQL snippet by ID, including its content."
|
||||
[snippet-id]
|
||||
(try
|
||||
{:structured_output
|
||||
(when snippet-id
|
||||
(when-let [snippet (snippets/get-native-query-snippet snippet-id)]
|
||||
(select-keys snippet [:id :name :description :content])))}
|
||||
(catch Exception e
|
||||
(metabot-v3.tools.u/handle-agent-error e))))
|
||||
@@ -0,0 +1,40 @@
|
||||
(ns metabase-enterprise.metabot-v3.tools.transforms
|
||||
(:require
|
||||
[metabase-enterprise.metabot-v3.tools.util :as metabot-v3.tools.u]
|
||||
[metabase-enterprise.transforms-python.api :as transforms-python.api]
|
||||
[metabase-enterprise.transforms.api :as api.transforms]
|
||||
[metabase-enterprise.transforms.core :as transforms]
|
||||
[metabase.util.malli.registry :as mr]))
|
||||
|
||||
(mr/def ::transform-source ::api.transforms/transform-source)
|
||||
(mr/def ::transform-target ::api.transforms/transform-target)
|
||||
|
||||
(defn get-transforms
|
||||
"Get a list of all known transforms."
|
||||
[]
|
||||
(try
|
||||
{:structured_output
|
||||
(->> (api.transforms/get-transforms)
|
||||
(into [] (comp (map #(select-keys % [:id :entity_id :name :description :source]))
|
||||
(filter #(or (transforms/python-transform? %)
|
||||
(transforms/native-query-transform? %))))))}
|
||||
(catch Exception e
|
||||
(metabot-v3.tools.u/handle-agent-error e))))
|
||||
|
||||
(defn get-transform-details
|
||||
"Get information about a transform."
|
||||
[transform-id]
|
||||
(try
|
||||
{:structured_output
|
||||
(api.transforms/get-transform transform-id)}
|
||||
(catch Exception e
|
||||
(metabot-v3.tools.u/handle-agent-error e))))
|
||||
|
||||
(defn get-transform-python-library-details
|
||||
"Get information about a Python library by path."
|
||||
[path]
|
||||
(try
|
||||
{:structured_output
|
||||
(transforms-python.api/get-python-library-by-path path)}
|
||||
(catch Exception e
|
||||
(metabot-v3.tools.u/handle-agent-error e))))
|
||||
@@ -1,6 +1,7 @@
|
||||
(ns metabase-enterprise.semantic-search.scoring
|
||||
(:require
|
||||
[clojure.core.memoize :as memoize]
|
||||
[clojure.set :as set]
|
||||
[honey.sql :as sql]
|
||||
[honey.sql.helpers :as sql.helpers]
|
||||
[medley.core :as m]
|
||||
@@ -175,11 +176,13 @@
|
||||
(into #{} (map name activity-feed/rv-models)))
|
||||
|
||||
(def ^:private appdb-scorer-models
|
||||
(into recent-views-models (map name search.scoring/bookmarked-models-and-sub-models)))
|
||||
(-> recent-views-models
|
||||
;; Add "transform" here because it's not a recent-views model, but should still be scored
|
||||
(conj "transform")
|
||||
(into (map name) search.scoring/bookmarked-models-and-sub-models)))
|
||||
|
||||
(comment
|
||||
(require '[clojure.set :as set]
|
||||
'[metabase.search.spec :as search.spec])
|
||||
(require '[metabase.search.spec :as search.spec])
|
||||
;; #{"segment" "database" "action" "indexed-entity"}
|
||||
(set/difference (set search.spec/search-models) appdb-scorer-models))
|
||||
|
||||
|
||||
@@ -85,14 +85,9 @@
|
||||
(api/check (transforms.util/check-feature-enabled transform)
|
||||
[402 (deferred-tru "Premium features required for this transform type are not enabled.")]))
|
||||
|
||||
(api.macros/defendpoint :get "/"
|
||||
(defn get-transforms
|
||||
"Get a list of transforms."
|
||||
[_route-params
|
||||
{:keys [last_run_start_time last_run_statuses tag_ids]} :-
|
||||
[:map
|
||||
[:last_run_start_time {:optional true} [:maybe ms/NonBlankString]]
|
||||
[:last_run_statuses {:optional true} [:maybe (ms/QueryVectorOf [:enum "started" "succeeded" "failed" "timeout"])]]
|
||||
[:tag_ids {:optional true} [:maybe (ms/QueryVectorOf ms/IntGreaterThanOrEqualToZero)]]]]
|
||||
[& {:keys [last_run_start_time last_run_statuses tag_ids]}]
|
||||
(api/check-superuser)
|
||||
(let [transforms (t2/select :model/Transform)]
|
||||
(into []
|
||||
@@ -102,6 +97,16 @@
|
||||
(map #(update % :last_run transforms.util/localize-run-timestamps)))
|
||||
(t2/hydrate transforms :last_run :transform_tag_ids))))
|
||||
|
||||
(api.macros/defendpoint :get "/"
|
||||
"Get a list of transforms."
|
||||
[_route-params
|
||||
query-params :-
|
||||
[:map
|
||||
[:last_run_start_time {:optional true} [:maybe ms/NonBlankString]]
|
||||
[:last_run_statuses {:optional true} [:maybe (ms/QueryVectorOf [:enum "started" "succeeded" "failed" "timeout"])]]
|
||||
[:tag_ids {:optional true} [:maybe (ms/QueryVectorOf ms/IntGreaterThanOrEqualToZero)]]]]
|
||||
(get-transforms query-params))
|
||||
|
||||
(api.macros/defendpoint :post "/"
|
||||
"Create a new transform."
|
||||
[_route-params
|
||||
@@ -129,11 +134,9 @@
|
||||
;; Return with hydrated tag_ids
|
||||
(t2/hydrate transform :transform_tag_ids))))
|
||||
|
||||
(api.macros/defendpoint :get "/:id"
|
||||
(defn get-transform
|
||||
"Get a specific transform."
|
||||
[{:keys [id]} :- [:map
|
||||
[:id ms/PositiveInt]]]
|
||||
(log/info "get transform" id)
|
||||
[id]
|
||||
(api/check-superuser)
|
||||
(let [{:keys [target] :as transform} (api/check-404 (t2/select-one :model/Transform id))
|
||||
target-table (transforms.util/target-table (transforms.util/target-database-id transform) target :active true)]
|
||||
@@ -142,6 +145,13 @@
|
||||
(u/update-some :last_run transforms.util/localize-run-timestamps)
|
||||
(assoc :table target-table))))
|
||||
|
||||
(api.macros/defendpoint :get "/:id"
|
||||
"Get a specific transform."
|
||||
[{:keys [id]} :- [:map
|
||||
[:id ms/PositiveInt]]]
|
||||
(log/info "get transform" id)
|
||||
(get-transform id))
|
||||
|
||||
(api.macros/defendpoint :get "/:id/dependencies"
|
||||
"Get the dependencies of a specific transform."
|
||||
[{:keys [id]} :- [:map
|
||||
|
||||
@@ -2,8 +2,13 @@
|
||||
"API namespace for the `metabase-enterprise.transform` module."
|
||||
(:require
|
||||
[metabase-enterprise.transforms.settings]
|
||||
[metabase-enterprise.transforms.util]
|
||||
[potemkin :as p]))
|
||||
|
||||
(p/import-vars
|
||||
[metabase-enterprise.transforms.settings
|
||||
transform-timeout])
|
||||
transform-timeout]
|
||||
[metabase-enterprise.transforms.util
|
||||
native-query-transform?
|
||||
python-transform?
|
||||
query-transform?])
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
[metabase-enterprise.transforms.models.transform-run :as transform-run]
|
||||
[metabase.models.interface :as mi]
|
||||
[metabase.models.serialization :as serdes]
|
||||
[metabase.search.ingestion :as search]
|
||||
[metabase.search.spec :as search.spec]
|
||||
[metabase.util :as u]
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
@@ -16,6 +18,11 @@
|
||||
(doseq [trait [:metabase/model :hook/entity-id :hook/timestamped?]]
|
||||
(derive :model/Transform trait))
|
||||
|
||||
;; Only superusers can access transforms
|
||||
(doto :model/Transform
|
||||
(derive ::mi/read-policy.superuser)
|
||||
(derive ::mi/write-policy.superuser))
|
||||
|
||||
(t2/deftransforms :model/Transform
|
||||
{:source mi/transform-json
|
||||
:target mi/transform-json
|
||||
@@ -152,3 +159,39 @@
|
||||
(defmethod serdes/storage-path "Transform" [transform _ctx]
|
||||
(let [{:keys [id label]} (-> transform serdes/path last)]
|
||||
["transforms" (serdes/storage-leaf-file-name id label)]))
|
||||
|
||||
(defn- maybe-extract-transform-query-text
|
||||
"Return the query text (truncated to `max-searchable-value-length`) from transform source; else nil.
|
||||
Extracts SQL from query-type transforms and Python code from python-type transforms."
|
||||
[{:keys [source]}]
|
||||
(let [source-data ((:out mi/transform-json) source)
|
||||
query-text (case (:type source-data)
|
||||
"query" (get-in source-data [:query :native :query])
|
||||
"python" (:body source-data)
|
||||
nil)]
|
||||
(when query-text
|
||||
(subs query-text 0 (min (count query-text) search/max-searchable-value-length)))))
|
||||
|
||||
(defn- extract-transform-db-id
|
||||
"Return the database ID from transform source; else nil."
|
||||
[{:keys [source]}]
|
||||
(let [parsed-source ((:out mi/transform-json) source)]
|
||||
(case (:type parsed-source)
|
||||
"query" (get-in parsed-source [:query :database])
|
||||
"python" (parsed-source :source-database)
|
||||
nil)))
|
||||
|
||||
;;; ------------------------------------------------- Search ---------------------------------------------------
|
||||
|
||||
(search.spec/define-spec "transform"
|
||||
{:model :model/Transform
|
||||
:attrs {:archived false
|
||||
:collection-id false
|
||||
:created-at true
|
||||
:updated-at true
|
||||
:native-query {:fn maybe-extract-transform-query-text
|
||||
:fields [:source]}
|
||||
:database-id {:fn extract-transform-db-id
|
||||
:fields [:source]}}
|
||||
:search-terms [:name :description]
|
||||
:render-terms {:description true}})
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
[metabase-enterprise.transforms.settings :as transforms.settings]
|
||||
[metabase.driver :as driver]
|
||||
[metabase.driver.common.parameters.dates :as params.dates]
|
||||
[metabase.lib.core :as lib]
|
||||
[metabase.lib.schema.common :as lib.schema.common]
|
||||
[metabase.premium-features.core :as premium-features :refer [defenterprise]]
|
||||
[metabase.query-processor.compile :as qp.compile]
|
||||
@@ -41,6 +42,12 @@
|
||||
[transform]
|
||||
(= :query (-> transform :source :type keyword)))
|
||||
|
||||
(defn native-query-transform?
|
||||
"Check if this is a native query transform"
|
||||
[transform]
|
||||
(and (query-transform? transform)
|
||||
(= :native (lib/normalized-query-type (-> transform :source :query)))))
|
||||
|
||||
(defn python-transform?
|
||||
"Check if this is a Python transform."
|
||||
[transform]
|
||||
|
||||
@@ -6,15 +6,20 @@
|
||||
[metabase.api.routes.common :refer [+auth]]
|
||||
[metabase.api.util.handlers :as handlers]))
|
||||
|
||||
(api.macros/defendpoint :get "/library/:path"
|
||||
"Get the Python library for user modules."
|
||||
[{:keys [path]} :- [:map [:path :string]]
|
||||
_query-params]
|
||||
(defn get-python-library-by-path
|
||||
"Get Python library details by path for use by other APIs."
|
||||
[path]
|
||||
(api/check-superuser)
|
||||
(-> (python-library/get-python-library-by-path path)
|
||||
api/check-404
|
||||
(select-keys [:source :path :created_at :updated_at])))
|
||||
|
||||
(api.macros/defendpoint :get "/library/:path"
|
||||
"Get the Python library for user modules."
|
||||
[{:keys [path]} :- [:map [:path :string]]
|
||||
_query-params]
|
||||
(get-python-library-by-path path))
|
||||
|
||||
(api.macros/defendpoint :put "/library/:path"
|
||||
"Update the Python library source code for user modules."
|
||||
[{:keys [path]} :- [:map [:path :string]]
|
||||
|
||||
@@ -133,6 +133,46 @@
|
||||
(is (= 0 @call-count)
|
||||
"Should not call database-tables-for-context for MBQL queries"))))))
|
||||
|
||||
(deftest enhance-context-with-schema-python-transform
|
||||
(testing "Enhances context with schema for Python transforms"
|
||||
(let [mock-tables [{:id 24 :name "orders" :schema "public"}
|
||||
{:id 31 :name "products" :schema "public"}]]
|
||||
(with-redefs [context/python-transform-tables-for-context
|
||||
(fn [_] mock-tables)]
|
||||
(let [input {:user_is_viewing [{:type "transform"
|
||||
:source {:type "python"
|
||||
:source-database 2
|
||||
:source-tables {:orders 24
|
||||
:products 31}}}]}
|
||||
result (#'context/enhance-context-with-schema input)
|
||||
used-tables (get-in result [:user_is_viewing 0 :used_tables])]
|
||||
(is (= mock-tables used-tables)))))))
|
||||
|
||||
(deftest enhance-context-with-schema-python-transform-no-source-tables
|
||||
(testing "Handles Python transform without source-tables"
|
||||
(let [called? (atom false)]
|
||||
(with-redefs [context/python-transform-tables-for-context
|
||||
(fn [_] (reset! called? true) nil)]
|
||||
(let [input {:user_is_viewing [{:type "transform"
|
||||
:source {:type "python"
|
||||
:source-database 2
|
||||
:source-tables {}}}]}
|
||||
result (#'context/enhance-context-with-schema input)]
|
||||
(is (nil? (get-in result [:user_is_viewing 0 :used_tables])))
|
||||
(is (false? @called?)))))))
|
||||
|
||||
(deftest enhance-context-with-schema-python-transform-no-source-database
|
||||
(testing "Handles Python transform without source-database"
|
||||
(let [called? (atom false)]
|
||||
(with-redefs [context/python-transform-tables-for-context
|
||||
(fn [_] (reset! called? true) nil)]
|
||||
(let [input {:user_is_viewing [{:type "transform"
|
||||
:source {:type "python"
|
||||
:source-tables {:orders 24}}}]}
|
||||
result (#'context/enhance-context-with-schema input)]
|
||||
(is (nil? (get-in result [:user_is_viewing 0 :used_tables])))
|
||||
(is (false? @called?)))))))
|
||||
|
||||
(deftest capabilities-signalling
|
||||
(testing "We signal our capabilities to ai-service"
|
||||
(is (= (count (-> (the-ns 'metabase-enterprise.metabot-v3.tools.api)
|
||||
|
||||
@@ -206,6 +206,54 @@
|
||||
(is (>= (count tables) 1)) ; At least the recognized table
|
||||
(is (some #(= table1-id (:id %)) tables)))))))))
|
||||
|
||||
(deftest used-tables-from-ids-test
|
||||
(testing "used-tables-from-ids function"
|
||||
(mt/with-temp [:model/Database {db-id :id} {}
|
||||
:model/Database {other-db-id :id} {}
|
||||
:model/Table {table1-id :id} {:db_id db-id, :name "users", :schema "public", :active true, :visibility_type nil}
|
||||
:model/Table {table2-id :id} {:db_id db-id, :name "orders", :schema "public", :active true, :visibility_type nil}
|
||||
:model/Table {table3-id :id} {:db_id db-id, :name "products", :schema "inventory", :active true, :visibility_type nil}
|
||||
:model/Table {inactive-id :id} {:db_id db-id, :name "old_data", :schema "public", :active false, :visibility_type nil}
|
||||
:model/Table {hidden-id :id} {:db_id db-id, :name "sensitive", :schema "public", :active true, :visibility_type :hidden}
|
||||
:model/Table {other-db-table-id :id} {:db_id other-db-id, :name "other_table", :schema "public", :active true, :visibility_type nil}]
|
||||
|
||||
(testing "returns tables with correct structure for valid table-ids"
|
||||
(mt/with-current-user (mt/user->id :crowberto)
|
||||
(is (= [{:id table1-id :name "users" :schema "public"}
|
||||
{:id table3-id :name "products" :schema "inventory"}]
|
||||
(table-utils/used-tables-from-ids db-id [table1-id table3-id])))))
|
||||
|
||||
(testing "handles empty table-ids collection"
|
||||
(mt/with-current-user (mt/user->id :crowberto)
|
||||
(is (empty? (table-utils/used-tables-from-ids db-id [])))))
|
||||
|
||||
(testing "filters by database-id"
|
||||
(mt/with-current-user (mt/user->id :crowberto)
|
||||
(is (= [{:id table1-id :name "users" :schema "public"}]
|
||||
(table-utils/used-tables-from-ids db-id [table1-id other-db-table-id])))))
|
||||
|
||||
(testing "filters out inactive tables"
|
||||
(mt/with-current-user (mt/user->id :crowberto)
|
||||
(is (= [{:id table1-id :name "users" :schema "public"}]
|
||||
(table-utils/used-tables-from-ids db-id [table1-id inactive-id])))))
|
||||
|
||||
(testing "filters out hidden tables"
|
||||
(mt/with-current-user (mt/user->id :crowberto)
|
||||
(is (= [{:id table1-id :name "users" :schema "public"}]
|
||||
(table-utils/used-tables-from-ids db-id [table1-id hidden-id])))))
|
||||
|
||||
(testing "handles non-existent table-ids"
|
||||
(mt/with-current-user (mt/user->id :crowberto)
|
||||
(let [fake-id 999999]
|
||||
(is (empty? (table-utils/used-tables-from-ids db-id [fake-id]))))))
|
||||
|
||||
(testing "handles mix of valid and invalid table-ids"
|
||||
(mt/with-current-user (mt/user->id :crowberto)
|
||||
(let [fake-id 999999]
|
||||
(is (= [{:id table1-id :name "users" :schema "public"}
|
||||
{:id table2-id :name "orders" :schema "public"}]
|
||||
(table-utils/used-tables-from-ids db-id [table1-id fake-id table2-id])))))))))
|
||||
|
||||
;; ======================================
|
||||
;; Edge Cases and Error Handling
|
||||
;; ======================================
|
||||
|
||||
@@ -1110,3 +1110,126 @@
|
||||
(request (assoc arguments
|
||||
:with_fields false
|
||||
:with_metrics false))))))))))
|
||||
|
||||
(deftest get-transforms-test
|
||||
(mt/with-premium-features #{:metabot-v3 :transforms}
|
||||
(let [conversation-id (str (random-uuid))
|
||||
rasta-ai-token (ai-session-token)
|
||||
crowberto-ai-token (ai-session-token :crowberto (str (random-uuid)))]
|
||||
(mt/with-temp [:model/Transform t1 {:name "People Transform"
|
||||
:description "Simple select on People table"
|
||||
:source {:type "query"
|
||||
:query (mt/native-query {:query "SELECT * FROM PEOPLE"})}
|
||||
:target {:type "table"
|
||||
:name "t1_table"}}
|
||||
:model/Transform t2 {:name "MBQL Transform"
|
||||
:description "Simple MQBL query on Products table"
|
||||
:source {:type "query"
|
||||
:query (mt/mbql-query products)}
|
||||
:target {:type "table"
|
||||
:name "t2_table"}}
|
||||
:model/Transform t3 {:name "Python Transform"
|
||||
:description "Simple python transform"
|
||||
:source {:type "python"
|
||||
:body "print('hello world')"
|
||||
:source_tables {}}
|
||||
:target {:type "table"
|
||||
:name "t2_table"}}]
|
||||
(testing "With insufficient permissions"
|
||||
(is (= "You don't have permissions to do that."
|
||||
(mt/user-http-request :rasta :post 403 "ee/metabot-tools/get-transforms"
|
||||
{:request-options {:headers {"x-metabase-session" rasta-ai-token}}}
|
||||
{:conversation_id conversation-id}))))
|
||||
(testing "With superuser permissions"
|
||||
(is (=? {:structured_output [(select-keys t1 [:id :entity_id :name :description :source])
|
||||
;; note: t2 not included because it's a (non-native) MBQL query
|
||||
(select-keys t3 [:id :entity_id :name :description :source])]
|
||||
:conversation_id conversation-id}
|
||||
(-> (mt/user-http-request :rasta :post 200 "ee/metabot-tools/get-transforms"
|
||||
{:request-options {:headers {"x-metabase-session" crowberto-ai-token}}}
|
||||
{:conversation_id conversation-id})
|
||||
(update :structured_output (fn [output]
|
||||
(filter #(#{(:id t1) (:id t2) (:id t3)} (:id %))
|
||||
output)))))))))))
|
||||
|
||||
(deftest get-transform-test
|
||||
(mt/with-premium-features #{:metabot-v3 :transforms}
|
||||
(let [conversation-id (str (random-uuid))
|
||||
rasta-ai-token (ai-session-token)
|
||||
crowberto-ai-token (ai-session-token :crowberto (str (random-uuid)))]
|
||||
(mt/with-temp [:model/Transform t1 {:name "People Transform"
|
||||
:description "Simple select on People table"
|
||||
:source {:type "query"
|
||||
:query (mt/native-query {:query "SELECT * FROM PEOPLE"})}
|
||||
:target {:type "table"
|
||||
:name "t1_table"}}
|
||||
:model/Transform t2 {:name "Python Transform"
|
||||
:description "Simple Python transform"
|
||||
:source {:type "python"
|
||||
:body "print('hello world')"
|
||||
:source_tables {}}
|
||||
:target {:type "table"
|
||||
:name "t2_table"}}]
|
||||
(testing "With insufficient permissions"
|
||||
(is (= "You don't have permissions to do that."
|
||||
(mt/user-http-request :rasta :post 403 "ee/metabot-tools/get-transform-details"
|
||||
{:request-options {:headers {"x-metabase-session" rasta-ai-token}}}
|
||||
{:arguments {:transform_id (:id t1)}
|
||||
:conversation_id conversation-id}))))
|
||||
(testing "With non-existent transform"
|
||||
(is (= "Not found."
|
||||
(mt/user-http-request :rasta :post 404 "ee/metabot-tools/get-transform-details"
|
||||
{:request-options {:headers {"x-metabase-session" crowberto-ai-token}}}
|
||||
{:arguments {:transform_id (+ 10000 (:id t2))}
|
||||
:conversation_id conversation-id}))))
|
||||
(testing "With superuser permissions"
|
||||
(doseq [transform [t1 t2]]
|
||||
(testing (:name transform)
|
||||
(is (=? {:structured_output transform
|
||||
:conversation_id conversation-id}
|
||||
(mt/user-http-request :rasta :post 200 "ee/metabot-tools/get-transform-details"
|
||||
{:request-options {:headers {"x-metabase-session" crowberto-ai-token}}}
|
||||
{:arguments {:transform_id (:id transform)}
|
||||
:conversation_id conversation-id}))))))))))
|
||||
|
||||
(deftest get-transform-python-library-details-test
|
||||
(mt/with-premium-features #{:metabot-v3 :python-transforms}
|
||||
(let [conversation-id (str (random-uuid))
|
||||
rasta-ai-token (ai-session-token)
|
||||
crowberto-ai-token (ai-session-token :crowberto (str (random-uuid)))
|
||||
saved-python-library (t2/select-one :model/PythonLibrary :path "common.py")]
|
||||
(when (seq saved-python-library)
|
||||
(t2/delete! :model/PythonLibrary))
|
||||
(try
|
||||
(testing "With no Python library present"
|
||||
(is (= "Not found."
|
||||
(mt/user-http-request :rasta :post 404 "ee/metabot-tools/get-transform-python-library-details"
|
||||
{:request-options {:headers {"x-metabase-session" crowberto-ai-token}}}
|
||||
{:arguments {:path "common.py"}
|
||||
:conversation_id conversation-id}))))
|
||||
(mt/with-temp [:model/PythonLibrary lib1 {:path "common.py"
|
||||
:source "def hello():\n return 'world'"}]
|
||||
(testing "With insufficient permissions"
|
||||
(is (= "You don't have permissions to do that."
|
||||
(mt/user-http-request :rasta :post 403 "ee/metabot-tools/get-transform-python-library-details"
|
||||
{:request-options {:headers {"x-metabase-session" rasta-ai-token}}}
|
||||
{:arguments {:path (:path lib1)}
|
||||
:conversation_id conversation-id}))))
|
||||
(testing "With non-existent library path"
|
||||
(is (=? {:allowed-paths ["common.py"]
|
||||
:message "Invalid library path. Only 'common' is currently supported."
|
||||
:path "nonexistent.py"}
|
||||
(mt/user-http-request :rasta :post 400 "ee/metabot-tools/get-transform-python-library-details"
|
||||
{:request-options {:headers {"x-metabase-session" crowberto-ai-token}}}
|
||||
{:arguments {:path "nonexistent.py"}
|
||||
:conversation_id conversation-id}))))
|
||||
(testing "With superuser permissions"
|
||||
(is (=? {:structured_output (select-keys lib1 [:source :path :created_at :updated_at])
|
||||
:conversation_id conversation-id}
|
||||
(mt/user-http-request :rasta :post 200 "ee/metabot-tools/get-transform-python-library-details"
|
||||
{:request-options {:headers {"x-metabase-session" crowberto-ai-token}}}
|
||||
{:arguments {:path (:path lib1)}
|
||||
:conversation_id conversation-id})))))
|
||||
(finally
|
||||
(when (seq saved-python-library)
|
||||
(t2/insert! :model/PythonLibrary saved-python-library)))))))
|
||||
|
||||
@@ -104,8 +104,8 @@
|
||||
;; Item 99 appears in all 5 lists at position 2, so should rank very high
|
||||
(is (= 99 (:id (first result)))))))
|
||||
|
||||
(deftest transform-search-result-test
|
||||
(testing "table result transformation"
|
||||
(deftest postprocess-search-result-test
|
||||
(testing "table result postprocessing"
|
||||
(let [result {:model "table"
|
||||
:id 1
|
||||
:table_name "orders"
|
||||
@@ -124,9 +124,9 @@
|
||||
:database_schema "public"
|
||||
:updated_at "2024-01-01"
|
||||
:created_at "2024-01-01"}]
|
||||
(is (= expected (#'search/transform-search-result result)))))
|
||||
(is (= expected (#'search/postprocess-search-result result)))))
|
||||
|
||||
(testing "model (dataset) result transformation"
|
||||
(testing "model (dataset) result postprocessing"
|
||||
(let [result {:model "dataset"
|
||||
:id 2
|
||||
:name "Sales Model"
|
||||
@@ -145,9 +145,26 @@
|
||||
:collection {}
|
||||
:updated_at "2024-01-02"
|
||||
:created_at "2024-01-02"}]
|
||||
(is (= expected (#'search/transform-search-result result)))))
|
||||
(is (= expected (#'search/postprocess-search-result result)))))
|
||||
|
||||
(testing "dashboard result transformation"
|
||||
(testing "transform result postprocessing"
|
||||
(let [result {:model "transform"
|
||||
:id 3
|
||||
:name "User Transform"
|
||||
:description "Transform for users"
|
||||
:database_id 44
|
||||
:updated_at "2024-01-03"
|
||||
:created_at "2024-01-03"}
|
||||
expected {:id 3
|
||||
:type "transform"
|
||||
:name "User Transform"
|
||||
:description "Transform for users"
|
||||
:database_id 44
|
||||
:updated_at "2024-01-03"
|
||||
:created_at "2024-01-03"}]
|
||||
(is (= expected (#'search/postprocess-search-result result)))))
|
||||
|
||||
(testing "dashboard result postprocessing"
|
||||
(let [result {:model "dashboard"
|
||||
:id 3
|
||||
:name "Main Dashboard"
|
||||
@@ -164,9 +181,9 @@
|
||||
:collection {:name "Finance" :authority_level "official"}
|
||||
:updated_at "2024-01-03"
|
||||
:created_at "2024-01-03"}]
|
||||
(is (= expected (#'search/transform-search-result result)))))
|
||||
(is (= expected (#'search/postprocess-search-result result)))))
|
||||
|
||||
(testing "question (card) result transformation with moderated_status"
|
||||
(testing "question (card) result postprocessing with moderated_status"
|
||||
(let [result {:model "card"
|
||||
:id 4
|
||||
:name "Q1"
|
||||
@@ -184,9 +201,9 @@
|
||||
:collection {:name "Analytics" :authority_level nil}
|
||||
:updated_at "2024-01-04"
|
||||
:created_at "2024-01-04"}]
|
||||
(is (= expected (#'search/transform-search-result result)))))
|
||||
(is (= expected (#'search/postprocess-search-result result)))))
|
||||
|
||||
(testing "metric result transformation"
|
||||
(testing "metric result postprocessing"
|
||||
(let [result {:model "metric"
|
||||
:id 5
|
||||
:name "Revenue"
|
||||
@@ -203,9 +220,9 @@
|
||||
:collection {}
|
||||
:updated_at "2024-01-05"
|
||||
:created_at "2024-01-05"}]
|
||||
(is (= expected (#'search/transform-search-result result)))))
|
||||
(is (= expected (#'search/postprocess-search-result result)))))
|
||||
|
||||
(testing "database result transformation"
|
||||
(testing "database result postprocessing"
|
||||
(let [result {:model "database"
|
||||
:id 6
|
||||
:name "Production DB"
|
||||
@@ -218,7 +235,7 @@
|
||||
:description "Main database"
|
||||
:updated_at "2024-01-06"
|
||||
:created_at "2024-01-06"}]
|
||||
(is (= expected (#'search/transform-search-result result))))))
|
||||
(is (= expected (#'search/postprocess-search-result result))))))
|
||||
|
||||
(deftest search-test
|
||||
(mt/with-premium-features #{:content-verification}
|
||||
@@ -240,20 +257,20 @@
|
||||
perms/sandboxed-user? (fn [] false)
|
||||
api/*current-user-id* 1]
|
||||
|
||||
(testing "search returns transformed results for term queries"
|
||||
(testing "search returns postprocessed results for term queries"
|
||||
(with-redefs [search-core/search (fn [_] {:data [order-table]})]
|
||||
(let [args {:term-queries ["orders"]
|
||||
:entity-types ["table"]}
|
||||
results (search/search args)
|
||||
expected [(#'search/transform-search-result order-table)]]
|
||||
expected [(#'search/postprocess-search-result order-table)]]
|
||||
(is (= expected results)))))
|
||||
|
||||
(testing "search returns transformed results for semantic queries"
|
||||
(testing "search returns postprocessed results for semantic queries"
|
||||
(with-redefs [search-core/search (fn [_] {:data [dashboard]})]
|
||||
(let [args {:semantic-queries ["sales metrics"]
|
||||
:entity-types ["dashboard"]}
|
||||
results (search/search args)
|
||||
expected [(#'search/transform-search-result dashboard)]]
|
||||
expected [(#'search/postprocess-search-result dashboard)]]
|
||||
(is (= expected results)))))
|
||||
|
||||
(testing "search combines term and semantic queries using RRF"
|
||||
|
||||
@@ -9,7 +9,10 @@
|
||||
[metabase.search.appdb.scoring-test :as appdb.scoring-test]
|
||||
[metabase.search.in-place.scoring :as scoring]
|
||||
[metabase.test :as mt]
|
||||
[metabase.util.json :as json]))
|
||||
[metabase.util.json :as json])
|
||||
(:import [java.time Instant]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(deftest ^:parallel verified-score-test
|
||||
(let [score #'ee-scoring/verified-score
|
||||
@@ -180,3 +183,31 @@
|
||||
(is (= [["card" 1 "card normal"]
|
||||
["card" 2 "card verified"]]
|
||||
(appdb.scoring-test/search-results* "card")))))))
|
||||
|
||||
(deftest ^:parallel transforms-user-recency-test
|
||||
(mt/with-premium-features #{:transforms}
|
||||
(let [user-id (mt/user->id :crowberto)
|
||||
now (Instant/now)
|
||||
recent-view (fn [model-id timestamp]
|
||||
{:model "card"
|
||||
:model_id model-id
|
||||
:user_id user-id
|
||||
:timestamp timestamp})]
|
||||
(mt/with-temp [:model/Card {c1 :id} {}
|
||||
:model/Card {c2 :id} {}
|
||||
:model/Transform {t1 :id} {:name "test transform"
|
||||
:source {:type "query"
|
||||
:query (mt/native-query {:query "SELECT 1"})}
|
||||
:target {:type "table"
|
||||
:name "test_table"}}
|
||||
:model/RecentViews _ (recent-view c1 now)]
|
||||
(appdb.scoring-test/with-index-contents
|
||||
[{:model "card" :id c1 :name "test card recent"}
|
||||
{:model "card" :id c2 :name "test card unseen"}
|
||||
{:model "transform" :id t1 :name "test transform"}]
|
||||
(testing "Transforms get a hardcoded 1-day recency (between recently viewed card and never viewed card)"
|
||||
(is (= [["card" c1 "test card recent"]
|
||||
["transform" t1 "test transform"]
|
||||
["card" c2 "test card unseen"]]
|
||||
(appdb.scoring-test/search-results :user-recency "test" {:current-user-id user-id
|
||||
:context :metabot})))))))))
|
||||
|
||||
@@ -324,3 +324,21 @@
|
||||
["card" c1 "card ancient"]
|
||||
["dataset" c3 "card unseen"]]
|
||||
(search-results :user-recency "card" {:current-user-id user-id})))))))))
|
||||
|
||||
(deftest transform-user-recency-test
|
||||
(testing "Transforms get a hard-coded intermediate user-recency score since view events are not tracked"
|
||||
(let [user-id (mt/user->id :crowberto)
|
||||
right-now (Instant/now)]
|
||||
(mt/with-temp [:model/Card {c1 :id} {}
|
||||
:model/RecentViews _ {:model "card" :model_id c1 :user_id user-id :timestamp right-now}]
|
||||
(with-index-contents!
|
||||
[{:model "card" :id c1 :name "recently viewed card"}
|
||||
{:model "transform" :id 1 :name "transform no views"}
|
||||
{:model "card" :id 2 :name "never viewed card"}]
|
||||
(testing "Transform ranks between recently viewed and never viewed cards"
|
||||
(let [results (search-results :user-recency "card" {:current-user-id user-id})]
|
||||
(def results results)
|
||||
(is (= [["card" c1 "recently viewed card"]
|
||||
["transform" 1 "transform no views"]
|
||||
["card" 2 "never viewed card"]]
|
||||
results)))))))))
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
(ns metabase-enterprise.transforms.search-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[java-time.api :as t]
|
||||
[metabase.app-db.core :as mdb]
|
||||
[metabase.search.appdb.index :as search.index]
|
||||
[metabase.search.engine :as search.engine]
|
||||
[metabase.search.ingestion :as search.ingestion]
|
||||
[metabase.search.test-util :as search.tu]
|
||||
[metabase.test :as mt]
|
||||
[metabase.test.fixtures :as fixtures]
|
||||
[toucan2.core :as t2])
|
||||
(:import
|
||||
(org.postgresql.util PGobject)))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(use-fixtures :once (fixtures/initialize :db :test-users))
|
||||
|
||||
(defn- ingest!
|
||||
[model where-clause]
|
||||
(#'search.engine/update!
|
||||
:search.engine/appdb
|
||||
(#'search.ingestion/query->documents
|
||||
(#'search.ingestion/spec-index-reducible model where-clause))))
|
||||
|
||||
(defn- fetch-one [model & clauses]
|
||||
(apply t2/select-one (search.index/active-table) :model model clauses))
|
||||
|
||||
(defn- ingest-then-fetch!
|
||||
[model entity-name]
|
||||
(ingest! model [:= :this.name entity-name])
|
||||
(fetch-one model :name entity-name))
|
||||
|
||||
(def ^:private default-index-entity
|
||||
{:model nil
|
||||
:model_id nil
|
||||
:name nil
|
||||
:official_collection nil
|
||||
:database_id nil
|
||||
:pinned nil
|
||||
:view_count nil
|
||||
:collection_id nil
|
||||
:last_viewed_at nil
|
||||
:model_created_at nil
|
||||
:model_updated_at nil
|
||||
:dashboardcard_count nil
|
||||
:last_edited_at nil
|
||||
:last_editor_id nil
|
||||
:verified nil})
|
||||
|
||||
(defn- index-entity
|
||||
[entity]
|
||||
(merge default-index-entity entity))
|
||||
|
||||
(deftest transform-ingestion-test
|
||||
(search.tu/with-temp-index-table
|
||||
(testing "A simple SQL transform gets properly ingested & indexed for search"
|
||||
(let [now (t/truncate-to (t/offset-date-time) :millis)]
|
||||
(mt/with-temp [:model/Transform {transform-id :id} {:name "Test transform"
|
||||
:description "A test transform"
|
||||
:source {:type "query"
|
||||
:query {:database (mt/id)
|
||||
:native {:query "SELECT 1"}}}
|
||||
:target {:database (mt/id)
|
||||
:table "test_table"}
|
||||
:created_at now
|
||||
:updated_at now}]
|
||||
(let [ingested-transform (ingest-then-fetch! "transform" "Test transform")]
|
||||
(is (=? (index-entity
|
||||
{:model "transform"
|
||||
:model_id (str transform-id)
|
||||
:name "Test transform"
|
||||
:database_id (mt/id)
|
||||
:model_created_at now
|
||||
:model_updated_at now})
|
||||
ingested-transform))))))
|
||||
|
||||
(testing "A simple Python transform gets properly ingested & indexed for search"
|
||||
(let [now (t/truncate-to (t/offset-date-time) :millis)]
|
||||
(mt/with-temp [:model/Transform {transform-id :id} {:name "Test Python transform"
|
||||
:description "A Python test transform"
|
||||
:source {:type "python"
|
||||
:source-database (mt/id)
|
||||
:body "import pandas as pd\n"}
|
||||
:target {:database (mt/id)}
|
||||
:created_at now
|
||||
:updated_at now}]
|
||||
(let [ingested-transform (ingest-then-fetch! "transform" "Test Python transform")]
|
||||
(is (=? (index-entity
|
||||
{:model "transform"
|
||||
:model_id (str transform-id)
|
||||
:name "Test Python transform"
|
||||
:database_id (mt/id)
|
||||
:model_created_at now
|
||||
:model_updated_at now})
|
||||
ingested-transform))))))
|
||||
|
||||
(testing "A simple MBQL transform gets properly ingested & indexed for search"
|
||||
(let [now (t/truncate-to (t/offset-date-time) :millis)]
|
||||
(mt/with-temp [:model/Transform {transform-id :id} {:name "Test MBQL transform"
|
||||
:description "An MBQL test transform"
|
||||
:source {:type "query"
|
||||
:query (mt/mbql-query venues {:limit 10})}
|
||||
:target {:database (mt/id)
|
||||
:table "test_mbql_table"}
|
||||
:created_at now
|
||||
:updated_at now}]
|
||||
(let [ingested-transform (ingest-then-fetch! "transform" "Test MBQL transform")]
|
||||
(is (=? (index-entity
|
||||
{:model "transform"
|
||||
:model_id (str transform-id)
|
||||
:name "Test MBQL transform"
|
||||
:database_id (mt/id)
|
||||
:model_created_at now
|
||||
:model_updated_at now})
|
||||
ingested-transform))))))))
|
||||
|
||||
(deftest transform-query-ingestion-test
|
||||
(testing "Contents of SQL and Python transform sources are extracted and indexed for full-text search"
|
||||
(when (= (mdb/db-type) :postgres)
|
||||
(mt/with-temp [:model/Transform _ {:target {:database (mt/id)
|
||||
:table "test_table"}
|
||||
:name "Test SQL transform"
|
||||
:source {:type "query"
|
||||
:query {:database (mt/id)
|
||||
:native {:query "SELECT 1"}}}}]
|
||||
|
||||
(let [ingested-transform (ingest-then-fetch! "transform" "Test SQL transform")
|
||||
vector-value (.getValue ^PGobject (:with_native_query_vector ingested-transform))]
|
||||
(is (string? vector-value))
|
||||
(is (re-find #"select" vector-value))
|
||||
(is (re-find #"sql" vector-value))))
|
||||
|
||||
(mt/with-temp [:model/Transform _ {:target {:database (mt/id)}
|
||||
:source {:type "python"
|
||||
:source-database (mt/id)
|
||||
:body "import pandas as pd\n"}
|
||||
:name "Test python transform"}]
|
||||
(let [ingested-transform (ingest-then-fetch! "transform" "Test python transform")
|
||||
vector-value (.getValue ^PGobject (:with_native_query_vector ingested-transform))]
|
||||
(is (string? vector-value))
|
||||
(is (re-find #"import" vector-value))
|
||||
(is (re-find #"panda" vector-value))))
|
||||
|
||||
(testing "MBQL queries are not indexed in with_native_query_vector"
|
||||
(mt/with-temp [:model/Transform _ {:target {:database (mt/id)
|
||||
:table "test_mbql_table"}
|
||||
:name "Test MBQL transform"
|
||||
:source {:type "query"
|
||||
:query (mt/mbql-query venues {:limit 10})}}]
|
||||
(let [ingested-transform (ingest-then-fetch! "transform" "Test MBQL transform")
|
||||
vector-value (.getValue ^PGobject (:with_native_query_vector ingested-transform))]
|
||||
(is (string? vector-value))
|
||||
(is (re-find #"test" vector-value))
|
||||
(is (re-find #"mbql" vector-value))
|
||||
;; Ensure that the actual MQBL query isn't indexed
|
||||
(is (not (re-find #"source" vector-value)))
|
||||
(is (not (re-find #"table" vector-value)))))))))
|
||||
@@ -1,5 +1,5 @@
|
||||
import cx from "classnames";
|
||||
import { useEffect, useState } from "react";
|
||||
import { type LegacyRef, useEffect, useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useSdkDispatch } from "embedding-sdk-bundle/store";
|
||||
@@ -104,7 +104,7 @@ export const MetabotChatEmbedding = () => {
|
||||
autosize
|
||||
minRows={1}
|
||||
maxRows={4}
|
||||
ref={metabot.promptInputRef}
|
||||
ref={metabot.promptInputRef as LegacyRef<HTMLTextAreaElement>}
|
||||
autoFocus
|
||||
value={metabot.prompt}
|
||||
disabled={metabot.isDoingScience}
|
||||
|
||||
@@ -1,16 +1,25 @@
|
||||
import * as Yup from "yup";
|
||||
|
||||
import type { MetabotTodoItem, SuggestedTransform } from "metabase-types/api";
|
||||
|
||||
export const dataPartSchema = Yup.object({
|
||||
type: Yup.string().required(),
|
||||
version: Yup.number().required(),
|
||||
value: Yup.mixed(),
|
||||
});
|
||||
|
||||
export const knownDataPartTypes = ["state", "navigate_to"];
|
||||
export const knownDataPartTypes = [
|
||||
"navigate_to",
|
||||
"state",
|
||||
"todo_list",
|
||||
"transform_suggestion",
|
||||
];
|
||||
|
||||
export type KnownDataPart =
|
||||
| { type: "navigate_to"; version: 1; value: string }
|
||||
| { type: "state"; version: 1; value: Record<string, any> }
|
||||
| { type: "navigate_to"; version: 1; value: string };
|
||||
| { type: "todo_list"; version: 1; value: MetabotTodoItem[] }
|
||||
| { type: "transform_suggestion"; version: 1; value: SuggestedTransform };
|
||||
|
||||
export const toolCallPartSchema = Yup.object({
|
||||
toolCallId: Yup.string().required(),
|
||||
|
||||
@@ -19,7 +19,6 @@ export const ENTERPRISE_TAG_TYPES = [
|
||||
"gsheets-status",
|
||||
"document",
|
||||
"comment",
|
||||
"transform",
|
||||
"transform-tag",
|
||||
"transform-job",
|
||||
"transform-job-via-tag",
|
||||
|
||||
@@ -10,8 +10,7 @@ import {
|
||||
Text,
|
||||
UnstyledButton,
|
||||
} from "metabase/ui";
|
||||
|
||||
import type { SuggestionModel } from "../types";
|
||||
import type { SuggestionModel } from "metabase-enterprise/rich_text_editing/tiptap/extensions/shared/types";
|
||||
|
||||
import S from "./MenuItems.module.css";
|
||||
|
||||
@@ -29,6 +28,7 @@ export interface MenuItem {
|
||||
model?: SuggestionModel;
|
||||
id?: number | string;
|
||||
href?: string;
|
||||
hasSubmenu?: boolean;
|
||||
}
|
||||
|
||||
export const MenuItemComponent = ({
|
||||
@@ -65,6 +65,10 @@ export const MenuItemComponent = ({
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
{item.hasSubmenu && (
|
||||
<Icon name="chevronright" size=".75rem" color="text-light" />
|
||||
)}
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
import type { SearchModel } from "metabase-types/api";
|
||||
|
||||
export interface CardEmbedRef {
|
||||
id: number;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export type SuggestionModel = SearchModel | "user";
|
||||
|
||||
@@ -47,3 +47,25 @@
|
||||
.aiMarkdown li > ol {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.smartLink {
|
||||
color: var(--mb-color-brand);
|
||||
font-weight: 700;
|
||||
padding: 0.125rem;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
transition: 200ms background-color;
|
||||
outline: none;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--mb-color-background-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.smartLinkInner {
|
||||
border-bottom: 1px solid var(--mb-color-brand-light);
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: -0.125rem;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
@@ -3,12 +3,14 @@
|
||||
import cx from "classnames";
|
||||
import { memo, useMemo } from "react";
|
||||
|
||||
import Link from "metabase/common/components/Link/Link";
|
||||
import Markdown, {
|
||||
type MarkdownProps,
|
||||
} from "metabase/common/components/Markdown";
|
||||
import { parseMetabaseProtocolLink } from "metabase-enterprise/metabot/utils/links";
|
||||
|
||||
import S from "./AIMarkdown.module.css";
|
||||
import { InternalLink } from "./components/InternalLink";
|
||||
import { MarkdownSmartLink } from "./components/MarkdownSmartLink";
|
||||
|
||||
type AIMarkdownProps = MarkdownProps & {
|
||||
onInternalLinkClick?: (link: string) => void;
|
||||
@@ -28,20 +30,22 @@ const getComponents = ({
|
||||
node?: any;
|
||||
[key: string]: any;
|
||||
}) => {
|
||||
if (href && href.startsWith("/")) {
|
||||
// A custom handler for internal links (use by Embedding SDK)
|
||||
if (onInternalLinkClick) {
|
||||
return (
|
||||
<a {...rest} onClick={() => onInternalLinkClick(href)}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
const parsed = parseMetabaseProtocolLink(node.properties.href);
|
||||
if (parsed) {
|
||||
return (
|
||||
<Link to={href} variant="brand">
|
||||
<MarkdownSmartLink
|
||||
onInternalLinkClick={onInternalLinkClick}
|
||||
name={String(node.children?.[0]?.value ?? "")}
|
||||
{...parsed}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (href?.startsWith("/")) {
|
||||
return (
|
||||
<InternalLink onInternalLinkClick={onInternalLinkClick} href={href}>
|
||||
{children}
|
||||
</Link>
|
||||
</InternalLink>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import Link from "metabase/common/components/Link";
|
||||
|
||||
export const InternalLink = ({
|
||||
onInternalLinkClick,
|
||||
href,
|
||||
children,
|
||||
...rest
|
||||
}: {
|
||||
onInternalLinkClick?: (href: string) => void;
|
||||
href: string;
|
||||
children: React.ReactNode;
|
||||
} & React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
if (onInternalLinkClick) {
|
||||
return (
|
||||
<a {...rest} onClick={() => onInternalLinkClick(href)}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to={href} variant="brand" {...rest}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { match } from "ts-pattern";
|
||||
|
||||
import { getIcon } from "metabase/lib/icon";
|
||||
import { modelToUrl } from "metabase/lib/urls";
|
||||
import { Icon } from "metabase/ui";
|
||||
import type { MetabaseProtocolEntityModel } from "metabase-enterprise/metabot/utils/links";
|
||||
import { useEntityData } from "metabase-enterprise/rich_text_editing/tiptap/extensions/SmartLink/SmartLinkNode";
|
||||
import { entityToUrlableModel } from "metabase-enterprise/rich_text_editing/tiptap/extensions/shared/suggestionUtils";
|
||||
|
||||
import S from "../AIMarkdown.module.css";
|
||||
|
||||
import { InternalLink } from "./InternalLink";
|
||||
|
||||
export const MarkdownSmartLink = ({
|
||||
onInternalLinkClick,
|
||||
id,
|
||||
name,
|
||||
model,
|
||||
}: {
|
||||
onInternalLinkClick?: (href: string) => void;
|
||||
id: number;
|
||||
name: string;
|
||||
model: MetabaseProtocolEntityModel;
|
||||
}) => {
|
||||
const entityModel = match(model)
|
||||
.with("model", () => "dataset" as const)
|
||||
.with("question", () => "card" as const)
|
||||
.otherwise((x) => x);
|
||||
const icon = getIcon({ model: entityModel });
|
||||
|
||||
const { entity, isLoading, error } = useEntityData(id, entityModel);
|
||||
|
||||
const entityUrl =
|
||||
!isLoading && !error && entity
|
||||
? (modelToUrl(entityToUrlableModel(entity, entityModel)) ?? "")
|
||||
: ""; // fallback to linking to nothing
|
||||
|
||||
return (
|
||||
<InternalLink
|
||||
onInternalLinkClick={onInternalLinkClick}
|
||||
href={entityUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={S.smartLink}
|
||||
>
|
||||
<span className={S.smartLinkInner}>
|
||||
<Icon name={icon.name} className={S.icon} />
|
||||
{name}
|
||||
</span>
|
||||
</InternalLink>
|
||||
);
|
||||
};
|
||||
@@ -4,17 +4,28 @@ import { tinykeys } from "tinykeys";
|
||||
import ErrorBoundary from "metabase/ErrorBoundary";
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import { getUser } from "metabase/selectors/user";
|
||||
import type { SuggestionModel } from "metabase-enterprise/rich_text_editing/tiptap/extensions/shared/types";
|
||||
|
||||
import { trackMetabotChatOpened } from "../analytics";
|
||||
import { useMetabotAgent } from "../hooks";
|
||||
|
||||
import { MetabotChat } from "./MetabotChat";
|
||||
|
||||
export interface MetabotProps {
|
||||
hide?: boolean;
|
||||
// TODO: add test coverage for these
|
||||
export interface MetabotConfig {
|
||||
emptyText?: string;
|
||||
hideSuggestedPrompts?: boolean;
|
||||
preventClose?: boolean;
|
||||
preventRetryMessage?: boolean;
|
||||
suggestionModels: SuggestionModel[];
|
||||
}
|
||||
|
||||
export const MetabotAuthenticated = ({ hide }: MetabotProps) => {
|
||||
export interface MetabotProps {
|
||||
hide?: boolean;
|
||||
config?: MetabotConfig;
|
||||
}
|
||||
|
||||
export const MetabotAuthenticated = ({ hide, config }: MetabotProps) => {
|
||||
const { visible, setVisible } = useMetabotAgent();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -44,7 +55,7 @@ export const MetabotAuthenticated = ({ hide }: MetabotProps) => {
|
||||
|
||||
return (
|
||||
<ErrorBoundary errorComponent={() => null}>
|
||||
<MetabotChat />
|
||||
<MetabotChat config={config} />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
.suggestionEditor {
|
||||
--change: var(--mb-color-success);
|
||||
--change-light: color-mix(in srgb, var(--change) 11%, transparent);
|
||||
--remove: var(--mb-color-danger);
|
||||
--remove-mid: color-mix(in srgb, var(--remove) 25%, transparent);
|
||||
--remove-light: color-mix(in srgb, var(--remove) 11%, transparent);
|
||||
|
||||
:global {
|
||||
.cm-content {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.cm-collapsedLines {
|
||||
background: var(--mb-color-bg-light) !important;
|
||||
color: var(--mb-color-text-light);
|
||||
padding-left: 0;
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
content: "-";
|
||||
color: var(--mb-color-text-light);
|
||||
}
|
||||
}
|
||||
|
||||
.cm-changedLineGutter {
|
||||
background: var(--change);
|
||||
}
|
||||
|
||||
.cm-deletedLineGutter {
|
||||
background: var(--remove);
|
||||
}
|
||||
|
||||
.cm-deletedChunk {
|
||||
background: var(--remove-light);
|
||||
}
|
||||
|
||||
.cm-deletedText {
|
||||
background: var(--remove-mid);
|
||||
}
|
||||
|
||||
.cm-merge-b {
|
||||
.cm-changedText {
|
||||
background: linear-gradient(var(--change), var(--change)) bottom / 100%
|
||||
2px no-repeat;
|
||||
}
|
||||
|
||||
.cm-changedLine {
|
||||
background: var(--change-light);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.suggestionEditorOnlyNew {
|
||||
background: color-mix(in srgb, var(--mb-color-success) 11%, transparent);
|
||||
padding-left: 4px;
|
||||
|
||||
:global {
|
||||
.cm-gutters {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.suggestionHeader {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.suggestionHeaderCollapsed {
|
||||
user-select: none;
|
||||
|
||||
* {
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
import { unifiedMergeView } from "@codemirror/merge";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import type { UnknownAction } from "@reduxjs/toolkit";
|
||||
import cx from "classnames";
|
||||
import { useMemo } from "react";
|
||||
import { push } from "react-router-redux";
|
||||
import { useLocation, useMount } from "react-use";
|
||||
import { P, match } from "ts-pattern";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { skipToken } from "metabase/api";
|
||||
import { CodeMirror } from "metabase/common/components/CodeMirror";
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import * as Urls from "metabase/lib/urls";
|
||||
import EditorS from "metabase/query_builder/components/NativeQueryEditor/CodeMirrorEditor/CodeMirrorEditor.module.css";
|
||||
import { getMetadata } from "metabase/selectors/metadata";
|
||||
import {
|
||||
Button,
|
||||
Collapse,
|
||||
Flex,
|
||||
Group,
|
||||
Icon,
|
||||
Loader,
|
||||
Paper,
|
||||
Text,
|
||||
} from "metabase/ui";
|
||||
import {
|
||||
useGetTransformQuery,
|
||||
useLazyGetTransformQuery,
|
||||
} from "metabase-enterprise/api";
|
||||
import {
|
||||
type MetabotAgentEditSuggestionChatMessage,
|
||||
setSuggestedTransform,
|
||||
} from "metabase-enterprise/metabot/state";
|
||||
import * as Lib from "metabase-lib";
|
||||
import Question from "metabase-lib/v1/Question";
|
||||
import type Metadata from "metabase-lib/v1/metadata/Metadata";
|
||||
import type {
|
||||
MetabotTransformInfo,
|
||||
QueryTransformSource,
|
||||
SuggestedTransform,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import S from "./MetabotAgentSuggestionMessage.module.css";
|
||||
|
||||
const PreviewContent = ({
|
||||
oldSource,
|
||||
newSource,
|
||||
}: {
|
||||
oldSource: string;
|
||||
newSource: string;
|
||||
}) => {
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
unifiedMergeView({
|
||||
original: oldSource,
|
||||
mergeControls: false,
|
||||
collapseUnchanged: {
|
||||
margin: 1,
|
||||
minSize: 1,
|
||||
},
|
||||
}),
|
||||
],
|
||||
[oldSource],
|
||||
);
|
||||
|
||||
return (
|
||||
<CodeMirror
|
||||
className={cx(
|
||||
EditorS.editor,
|
||||
S.suggestionEditor,
|
||||
!oldSource && S.suggestionEditorOnlyNew,
|
||||
)}
|
||||
extensions={extensions}
|
||||
value={newSource}
|
||||
readOnly
|
||||
autoCorrect="off"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const useGetOldTransform = ({
|
||||
editorTransform,
|
||||
suggestedTransform,
|
||||
}: MetabotAgentEditSuggestionChatMessage["payload"]) => {
|
||||
const [trigger, result] = useLazyGetTransformQuery();
|
||||
useMount(() => {
|
||||
!editorTransform && suggestedTransform.id && trigger(suggestedTransform.id);
|
||||
});
|
||||
|
||||
if (editorTransform) {
|
||||
return {
|
||||
data: editorTransform,
|
||||
isLoading: false,
|
||||
error: undefined,
|
||||
} as const;
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
const parseTemplateTags = (
|
||||
source: QueryTransformSource,
|
||||
metadata: Metadata,
|
||||
): QueryTransformSource => {
|
||||
// For unsaved native queries, ensure template tags (like #{{123-my-model}}) are parsed from query text
|
||||
const query = source.query;
|
||||
let question = Question.create({ dataset_query: query, metadata });
|
||||
const { isNative } = Lib.queryDisplayInfo(question.query());
|
||||
|
||||
if (isNative && !question.isSaved()) {
|
||||
question = question.setQuery(
|
||||
Lib.withNativeQuery(
|
||||
question.query(),
|
||||
Lib.rawNativeQuery(question.query()),
|
||||
),
|
||||
);
|
||||
|
||||
return {
|
||||
...source,
|
||||
query: question.datasetQuery(),
|
||||
};
|
||||
} else {
|
||||
return source;
|
||||
}
|
||||
};
|
||||
|
||||
export const AgentSuggestionMessage = ({
|
||||
message,
|
||||
}: {
|
||||
message: MetabotAgentEditSuggestionChatMessage;
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const metadata = useSelector(getMetadata);
|
||||
|
||||
const { suggestedTransform } = message.payload;
|
||||
const isNew = !suggestedTransform.id;
|
||||
|
||||
const url = useLocation();
|
||||
const transformUrl = getTransformUrl(suggestedTransform);
|
||||
const isViewing = !!url.pathname?.startsWith(transformUrl);
|
||||
|
||||
const [opened, { toggle }] = useDisclosure(!isViewing);
|
||||
|
||||
const {
|
||||
data: originalTransform,
|
||||
isLoading,
|
||||
error,
|
||||
} = useGetOldTransform(message.payload);
|
||||
|
||||
const { data: latestTransform } = useGetTransformQuery(
|
||||
suggestedTransform.id || skipToken,
|
||||
);
|
||||
|
||||
const oldSource = originalTransform ? getSourceCode(originalTransform) : "";
|
||||
const newSource = getSourceCode(suggestedTransform);
|
||||
const latestSource = latestTransform
|
||||
? getSourceCode(latestTransform)
|
||||
: undefined;
|
||||
|
||||
const isStale = latestSource && oldSource !== latestSource;
|
||||
|
||||
const handleFocus = () => {
|
||||
const transform = processTransform(suggestedTransform, metadata);
|
||||
dispatch(setSuggestedTransform(transform));
|
||||
dispatch(push(transformUrl) as UnknownAction);
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
shadow="none"
|
||||
radius="md"
|
||||
bg="white"
|
||||
style={{ border: `1px solid var(--mb-color-border)` }}
|
||||
>
|
||||
<Group
|
||||
p="md"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
onClick={toggle}
|
||||
style={{
|
||||
borderBottom: opened ? `1px solid var(--mb-color-border)` : "",
|
||||
}}
|
||||
>
|
||||
<Flex align="center" gap="sm">
|
||||
<Icon name="refresh_downstream" size="1rem" c="text-secondary" />
|
||||
<Text size="sm">{suggestedTransform.name}</Text>
|
||||
</Flex>
|
||||
<Flex align="center" gap="sm">
|
||||
<Text
|
||||
size="sm"
|
||||
c={isNew ? "var(--mb-color-saturated-blue)" : "text-secondary"}
|
||||
>
|
||||
{isNew ? t`New` : t`Revision`}
|
||||
</Text>
|
||||
<Flex align="center" justify="center" h="md" w="md">
|
||||
<Icon name={opened ? "chevrondown" : "chevronup"} size=".75rem" />
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Group>
|
||||
|
||||
<Collapse
|
||||
in={opened}
|
||||
transitionDuration={0}
|
||||
transitionTimingFunction="linear"
|
||||
>
|
||||
{match({ isLoading, error })
|
||||
.with({ error: P.not(P.nullish) }, () => (
|
||||
<Flex p="md" bg="bg-light" justify="center" align="center" gap="sm">
|
||||
<Text mb="1px" c="danger">{t`Failed to load preview`}</Text>
|
||||
</Flex>
|
||||
))
|
||||
.with({ isLoading: true }, () => (
|
||||
<Flex p="md" bg="bg-light" justify="center" align="center" gap="sm">
|
||||
<Loader size="xs" color="text-secondary" type="dots" />
|
||||
<Text mb="1px" c="text-secondary">{t`Loading preview`}</Text>
|
||||
</Flex>
|
||||
))
|
||||
.with({ isLoading: false }, () => (
|
||||
<PreviewContent oldSource={oldSource} newSource={newSource} />
|
||||
))
|
||||
.exhaustive()}
|
||||
|
||||
<Group
|
||||
py="xs"
|
||||
px="sm"
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style={{
|
||||
borderTop: opened ? `1px solid var(--mb-color-border)` : "",
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
align="center"
|
||||
justify="flex-end"
|
||||
w="100%"
|
||||
h="1.375rem"
|
||||
gap="sm"
|
||||
>
|
||||
{!isStale && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
disabled={isViewing}
|
||||
variant="subtle"
|
||||
fw="normal"
|
||||
fz="sm"
|
||||
c={isViewing ? "text-lighter" : "text-secondary"}
|
||||
onClick={() => handleFocus()}
|
||||
>
|
||||
{isViewing ? t`Focused` : t`Focus`}
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</Group>
|
||||
</Collapse>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
function getSourceCode(
|
||||
transform: Pick<MetabotTransformInfo, "source">,
|
||||
): string {
|
||||
return match(transform)
|
||||
.with(
|
||||
{ source: { type: "query", query: { type: "native" } } },
|
||||
(t) => t.source.query.native.query,
|
||||
)
|
||||
.with({ source: { type: "python" } }, (t) => t.source.body)
|
||||
.otherwise(() => "");
|
||||
}
|
||||
|
||||
function processTransform(transform: SuggestedTransform, metadata: Metadata) {
|
||||
const processedSource =
|
||||
transform.source.type === "query"
|
||||
? parseTemplateTags(transform.source, metadata)
|
||||
: transform.source;
|
||||
return {
|
||||
...transform,
|
||||
source: processedSource,
|
||||
};
|
||||
}
|
||||
|
||||
function getTransformUrl(transform: SuggestedTransform): string {
|
||||
return match(transform)
|
||||
.with({ id: P.number }, ({ id }) => Urls.transformEdit(id))
|
||||
.with({ source: { type: "python" } }, Urls.newPythonTransform)
|
||||
.with({ source: { type: "query" } }, Urls.newNativeTransform)
|
||||
.exhaustive();
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
.todoContainer {
|
||||
border: 1px solid var(--mb-color-brand);
|
||||
background-color: var(--mb-color-brand-lighter);
|
||||
}
|
||||
|
||||
.todoHeader {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.primaryColor {
|
||||
color: var(--mb-color-text-brand);
|
||||
}
|
||||
|
||||
.todoItem {
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.statusIndicator {
|
||||
height: 0.8rem;
|
||||
width: 0.8rem;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
background: var(--mb-color-brand);
|
||||
}
|
||||
|
||||
.pendingIndicator {
|
||||
border: 1.5px solid var(--mb-color-brand);
|
||||
margin-top: 1px;
|
||||
background: none;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import cx from "classnames";
|
||||
import { match } from "ts-pattern";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { Collapse, Flex, Group, Icon, Paper, Stack, Text } from "metabase/ui";
|
||||
import type { MetabotTodoItem } from "metabase-types/api";
|
||||
|
||||
import S from "./MetabotAgentTodoMessage.module.css";
|
||||
|
||||
type TodoStatusConfig = {
|
||||
color: string;
|
||||
td?: string;
|
||||
};
|
||||
|
||||
const todoStatusConfig: Record<MetabotTodoItem["status"], TodoStatusConfig> = {
|
||||
completed: { color: "text-secondary", td: "line-through" },
|
||||
in_progress: { color: "text-primary" },
|
||||
cancelled: { td: "line-through", color: "text-secondary" },
|
||||
pending: { color: "text-primary" },
|
||||
};
|
||||
|
||||
export const AgentTodoListMessage = ({
|
||||
todos,
|
||||
}: {
|
||||
todos: MetabotTodoItem[];
|
||||
}) => {
|
||||
const [opened, { toggle }] = useDisclosure(true);
|
||||
|
||||
return (
|
||||
<Paper shadow="none" radius="md" className={S.todoContainer}>
|
||||
<Group
|
||||
align="center"
|
||||
justify="space-between"
|
||||
onClick={toggle}
|
||||
className={S.todoHeader}
|
||||
py="sm"
|
||||
px="md"
|
||||
>
|
||||
<Flex align="center" justify="center">
|
||||
<Icon name="list" size=".75rem" mr="xs" c="brand" />
|
||||
<Text size="sm" fw="bold" c="brand">{t`Todo List`}</Text>
|
||||
</Flex>
|
||||
<Flex align="center" justify="center" h="md">
|
||||
<Icon
|
||||
name={opened ? "chevrondown" : "chevronup"}
|
||||
size=".75rem"
|
||||
c="brand"
|
||||
/>
|
||||
</Flex>
|
||||
</Group>
|
||||
|
||||
<Collapse
|
||||
in={opened}
|
||||
transitionDuration={opened ? 200 : 0}
|
||||
pb="sm"
|
||||
px="md"
|
||||
>
|
||||
<Stack gap="sm" w="100%" pb="xs">
|
||||
{todos.map((todo) => {
|
||||
const config = todoStatusConfig[todo.status];
|
||||
|
||||
return (
|
||||
<Flex key={todo.id} bdrs="sm" align="flex-start">
|
||||
{match(todo.status)
|
||||
.with("pending", () => (
|
||||
<Flex
|
||||
className={cx(S.statusIndicator, S.pendingIndicator)}
|
||||
align="center"
|
||||
justify="center"
|
||||
mr="sm"
|
||||
/>
|
||||
))
|
||||
.with("completed", () => (
|
||||
<Flex
|
||||
className={S.statusIndicator}
|
||||
align="center"
|
||||
justify="center"
|
||||
mr="sm"
|
||||
>
|
||||
<Icon name="check" size=".7rem" c="white" />
|
||||
</Flex>
|
||||
))
|
||||
.with("in_progress", () => (
|
||||
<Flex
|
||||
className={S.statusIndicator}
|
||||
align="center"
|
||||
justify="center"
|
||||
mr="sm"
|
||||
>
|
||||
<Icon name="arrow_right" size=".6rem" c="white" />
|
||||
</Flex>
|
||||
))
|
||||
.with("cancelled", () => (
|
||||
<Flex
|
||||
className={S.statusIndicator}
|
||||
bg="brand"
|
||||
align="center"
|
||||
justify="center"
|
||||
mr="sm"
|
||||
>
|
||||
<Icon name="close" size=".7rem" c="white" />
|
||||
</Flex>
|
||||
))
|
||||
.exhaustive()}
|
||||
<Text td={config.td} lh="md" size="sm" c={config.color}>
|
||||
{todo.content}
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Collapse>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
@@ -18,7 +18,7 @@
|
||||
}
|
||||
|
||||
.inputContainer {
|
||||
padding: 0 0.75rem 0 0.25rem;
|
||||
padding: 0 0.75rem 0 0.3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
@@ -27,7 +27,7 @@
|
||||
min-height: 3rem;
|
||||
border: 1px solid var(--mb-color-border);
|
||||
|
||||
&:has(.textarea textarea:focus) {
|
||||
&:focus-within {
|
||||
border-color: var(--mb-color-brand);
|
||||
}
|
||||
}
|
||||
@@ -36,36 +36,6 @@
|
||||
background: var(--mb-color-bg-light);
|
||||
}
|
||||
|
||||
.inputContainerExpanded {
|
||||
padding: 1rem var(--padding-x);
|
||||
align-items: flex-start;
|
||||
height: 8rem;
|
||||
}
|
||||
|
||||
.textarea textarea {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0;
|
||||
line-height: 1.7;
|
||||
color: var(--mb-color-text-dark);
|
||||
|
||||
&:focus {
|
||||
border-color: var(--mb-color-brand);
|
||||
}
|
||||
}
|
||||
|
||||
.textareaExpanded textarea {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.textareaLoading textarea:disabled {
|
||||
opacity: 1;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.messagesContainer {
|
||||
padding: 1rem var(--padding-x) 1.5rem;
|
||||
height: 100%;
|
||||
@@ -93,6 +63,12 @@
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.messageContainerUser :global(.smart-link) {
|
||||
border: 1px solid var(--mb-color-brand-light);
|
||||
position: relative;
|
||||
bottom: -2px;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.4 !important;
|
||||
@@ -104,6 +80,14 @@
|
||||
background: var(--mb-color-bg-medium);
|
||||
}
|
||||
|
||||
.messageUserAction {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--mb-color-bg-light);
|
||||
border: 1px solid var(--mb-color-bg-medium);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.messageActions {
|
||||
opacity: 0;
|
||||
transition: all 150ms linear;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import cx from "classnames";
|
||||
import { useMemo } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { t } from "ttag";
|
||||
import _ from "underscore";
|
||||
|
||||
import EmptyDashboardBot from "assets/img/dashboard-empty.svg?component";
|
||||
import { Sidebar } from "metabase/nav/containers/MainNavbar/MainNavbar.styled";
|
||||
@@ -14,23 +13,39 @@ import {
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from "metabase/ui";
|
||||
import { useGetSuggestedMetabotPromptsQuery } from "metabase-enterprise/api";
|
||||
import { MetabotResetLongChatButton } from "metabase-enterprise/metabot/components/MetabotChat/MetabotResetLongChatButton";
|
||||
|
||||
import { useMetabotAgent, useMetabotChatHandlers } from "../../hooks";
|
||||
import type { MetabotConfig } from "../Metabot";
|
||||
|
||||
import Styles from "./MetabotChat.module.css";
|
||||
import { MetabotChatEditor } from "./MetabotChatEditor";
|
||||
import { Messages } from "./MetabotChatMessage";
|
||||
import { MetabotThinking } from "./MetabotThinking";
|
||||
import { useScrollManager } from "./hooks";
|
||||
|
||||
export const MetabotChat = () => {
|
||||
const defaultConfig: MetabotConfig = {
|
||||
suggestionModels: [
|
||||
"dataset",
|
||||
"metric",
|
||||
"card",
|
||||
"table",
|
||||
"database",
|
||||
"dashboard",
|
||||
],
|
||||
};
|
||||
|
||||
export const MetabotChat = ({
|
||||
config = defaultConfig,
|
||||
}: {
|
||||
config?: MetabotConfig;
|
||||
}) => {
|
||||
const metabot = useMetabotAgent();
|
||||
const { handleSubmitInput, handleRetryMessage, handleResetInput } =
|
||||
useMetabotChatHandlers();
|
||||
useMetabotChatHandlers(config);
|
||||
|
||||
const hasMessages =
|
||||
metabot.messages.length > 0 || metabot.errorMessages.length > 0;
|
||||
@@ -52,6 +67,15 @@ export const MetabotChat = () => {
|
||||
metabot.setVisible(false);
|
||||
};
|
||||
|
||||
const handleEditorChange = useCallback(
|
||||
(value: string) => metabot.setPrompt(value),
|
||||
[metabot],
|
||||
);
|
||||
|
||||
const handleEditorSubmit = useCallback(() => {
|
||||
handleSubmitInput(metabot.prompt);
|
||||
}, [handleSubmitInput, metabot.prompt]);
|
||||
|
||||
return (
|
||||
<Sidebar
|
||||
isOpen={metabot.visible}
|
||||
@@ -64,8 +88,8 @@ export const MetabotChat = () => {
|
||||
<Box ref={headerRef} className={Styles.header}>
|
||||
<Flex align-items="center">
|
||||
<Text lh={1} fz="sm" c="text-secondary">
|
||||
{metabot.profile
|
||||
? t`Using profile: ${metabot.profile}`
|
||||
{metabot.profileOverride
|
||||
? t`Using profile: ${metabot.profileOverride}`
|
||||
: t`Metabot isn't perfect. Double-check results.`}
|
||||
</Text>
|
||||
</Flex>
|
||||
@@ -79,9 +103,14 @@ export const MetabotChat = () => {
|
||||
<Icon c="text-primary" name="revert" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<ActionIcon onClick={handleClose} data-testid="metabot-close-chat">
|
||||
<Icon c="text-primary" name="close" />
|
||||
</ActionIcon>
|
||||
{!config.preventClose && (
|
||||
<ActionIcon
|
||||
onClick={handleClose}
|
||||
data-testid="metabot-close-chat"
|
||||
>
|
||||
<Icon c="text-primary" name="close" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
|
||||
@@ -103,33 +132,33 @@ export const MetabotChat = () => {
|
||||
data-testid="metabot-empty-chat-info"
|
||||
>
|
||||
<Box component={EmptyDashboardBot} w="6rem" />
|
||||
<Text
|
||||
c="text-light"
|
||||
maw="12rem"
|
||||
ta="center"
|
||||
>{t`I can help you explore your metrics and models.`}</Text>
|
||||
<Text c="text-light" maw="12rem" ta="center">
|
||||
{config.emptyText ??
|
||||
t`I can help you explore your metrics and models.`}
|
||||
</Text>
|
||||
</Flex>
|
||||
{/* empty state with suggested prompts */}
|
||||
<Stack
|
||||
gap="sm"
|
||||
className={Styles.promptSuggestionsContainer}
|
||||
data-testid="metabot-prompt-suggestions"
|
||||
>
|
||||
<>
|
||||
{suggestedPrompts.map(({ prompt }, index) => (
|
||||
<Box key={index}>
|
||||
<Button
|
||||
fz="sm"
|
||||
size="xs"
|
||||
onClick={() => handleSubmitInput(prompt)}
|
||||
className={Styles.promptSuggestionButton}
|
||||
>
|
||||
{prompt}
|
||||
</Button>
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
</Stack>
|
||||
{!config.hideSuggestedPrompts && (
|
||||
<Stack
|
||||
gap="sm"
|
||||
className={Styles.promptSuggestionsContainer}
|
||||
data-testid="metabot-prompt-suggestions"
|
||||
>
|
||||
<>
|
||||
{suggestedPrompts.map(({ prompt }, index) => (
|
||||
<Box key={index}>
|
||||
<Button
|
||||
fz="sm"
|
||||
size="xs"
|
||||
onClick={() => handleSubmitInput(prompt)}
|
||||
className={Styles.promptSuggestionButton}
|
||||
>
|
||||
{prompt}
|
||||
</Button>
|
||||
</Box>
|
||||
))}
|
||||
</>
|
||||
</Stack>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -146,20 +175,12 @@ export const MetabotChat = () => {
|
||||
isDoingScience={metabot.isDoingScience}
|
||||
showFeedbackButtons
|
||||
/>
|
||||
|
||||
{/* loading */}
|
||||
{metabot.isDoingScience && (
|
||||
<MetabotThinking
|
||||
toolCalls={metabot.toolCalls}
|
||||
hasStartedResponse={
|
||||
_.last(metabot.messages)?.role === "agent"
|
||||
}
|
||||
/>
|
||||
<MetabotThinking toolCalls={metabot.toolCalls} />
|
||||
)}
|
||||
|
||||
{/* filler - height gets set via ref mutation */}
|
||||
<div ref={fillerRef} data-testid="metabot-message-filler" />
|
||||
|
||||
{/* long convo warning */}
|
||||
{metabot.isLongConversation && <MetabotResetLongChatButton />}
|
||||
</Box>
|
||||
@@ -172,41 +193,17 @@ export const MetabotChat = () => {
|
||||
Styles.inputContainer,
|
||||
metabot.isDoingScience && Styles.inputContainerLoading,
|
||||
)}
|
||||
data-testid="metabot-chat-input"
|
||||
>
|
||||
<Textarea
|
||||
id="metabot-chat-input"
|
||||
data-testid="metabot-chat-input"
|
||||
w="100%"
|
||||
leftSection={
|
||||
<Box h="100%" pt="11px">
|
||||
<Icon name="metabot" c="brand" />
|
||||
</Box>
|
||||
}
|
||||
autosize
|
||||
minRows={1}
|
||||
maxRows={10}
|
||||
<MetabotChatEditor
|
||||
ref={metabot.promptInputRef}
|
||||
autoFocus
|
||||
value={metabot.prompt}
|
||||
className={cx(
|
||||
Styles.textarea,
|
||||
metabot.isDoingScience && Styles.textareaLoading,
|
||||
)}
|
||||
autoFocus
|
||||
disabled={metabot.isDoingScience}
|
||||
placeholder={t`Tell me to do something, or ask a question`}
|
||||
onChange={(e) => metabot.setPrompt(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.nativeEvent.isComposing) {
|
||||
return;
|
||||
}
|
||||
const isModifiedKeyPress =
|
||||
e.shiftKey || e.ctrlKey || e.metaKey || e.altKey;
|
||||
if (e.key === "Enter" && !isModifiedKeyPress) {
|
||||
// prevent event from inserting new line + interacting with other content
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSubmitInput(metabot.prompt);
|
||||
}
|
||||
}}
|
||||
onChange={handleEditorChange}
|
||||
onSubmit={handleEditorSubmit}
|
||||
suggestionModels={config.suggestionModels}
|
||||
/>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
.editorContainer {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.iconContainer {
|
||||
padding-top: 12px;
|
||||
width: 2.375rem;
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.contentWrapper {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.content :global(.ProseMirror) {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
padding: 0.5rem 0;
|
||||
line-height: 1.7;
|
||||
color: var(--mb-color-text-dark);
|
||||
font-size: var(--mantine-font-size-md);
|
||||
min-height: 1.7em;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.content :global(.ProseMirror p) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.content :global(.is-empty:first-child:before) {
|
||||
color: var(--mb-color-text-light);
|
||||
content: attr(data-placeholder);
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.content.disabled :global(.ProseMirror) {
|
||||
opacity: 1;
|
||||
background: transparent;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.smartLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0 0.25rem;
|
||||
background: var(--mb-color-background-hover);
|
||||
border-radius: 0.25rem;
|
||||
color: var(--mb-color-brand);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import Document from "@tiptap/extension-document";
|
||||
import HardBreak from "@tiptap/extension-hard-break";
|
||||
import Paragraph from "@tiptap/extension-paragraph";
|
||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
||||
import Text from "@tiptap/extension-text";
|
||||
import { EditorContent, useEditor } from "@tiptap/react";
|
||||
import cx from "classnames";
|
||||
import { forwardRef, useEffect, useImperativeHandle, useRef } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import type { MetabotChatInputRef } from "metabase/metabot";
|
||||
import { getSetting } from "metabase/selectors/settings";
|
||||
import { Box, Icon } from "metabase/ui";
|
||||
import { createMentionSuggestion } from "metabase-enterprise/rich_text_editing/tiptap/extensions/Mention/MentionSuggestion";
|
||||
import {
|
||||
MetabotMentionExtension,
|
||||
MetabotMentionPluginKey,
|
||||
} from "metabase-enterprise/rich_text_editing/tiptap/extensions/MetabotMention/MetabotMentionExtension";
|
||||
import { SmartLink } from "metabase-enterprise/rich_text_editing/tiptap/extensions/SmartLink/SmartLinkNode";
|
||||
import type { SuggestionModel } from "metabase-enterprise/rich_text_editing/tiptap/extensions/shared/types";
|
||||
import { createSuggestionRenderer } from "metabase-enterprise/rich_text_editing/tiptap/extensions/suggestionRenderer";
|
||||
|
||||
import S from "./MetabotChatEditor.module.css";
|
||||
import {
|
||||
parseMetabotMessageToTiptapDoc,
|
||||
serializeTiptapToMetabotMessage,
|
||||
} from "./utils";
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
autoFocus?: boolean;
|
||||
disabled?: boolean;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
suggestionModels: SuggestionModel[];
|
||||
}
|
||||
|
||||
export const MetabotChatEditor = forwardRef<MetabotChatInputRef | null, Props>(
|
||||
(
|
||||
{
|
||||
value,
|
||||
placeholder = t`Tell me to do something, or ask a question`,
|
||||
autoFocus = false,
|
||||
disabled = false,
|
||||
suggestionModels,
|
||||
onChange,
|
||||
onSubmit,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const siteUrl = useSelector((state) => getSetting(state, "site-url"));
|
||||
const serializedRef = useRef(value);
|
||||
|
||||
// Use refs to avoid recreating the editor when callbacks change - not doing so prepends all characters
|
||||
const onChangeRef = useRef(onChange);
|
||||
const onSubmitRef = useRef(onSubmit);
|
||||
useEffect(() => {
|
||||
onChangeRef.current = onChange;
|
||||
onSubmitRef.current = onSubmit;
|
||||
});
|
||||
|
||||
const extensions = [
|
||||
Document,
|
||||
Paragraph,
|
||||
Text,
|
||||
Placeholder.configure({ placeholder }),
|
||||
HardBreak,
|
||||
SmartLink.configure({
|
||||
HTMLAttributes: { class: S.smartLink },
|
||||
siteUrl,
|
||||
}),
|
||||
MetabotMentionExtension.configure({
|
||||
suggestion: {
|
||||
render: createSuggestionRenderer(
|
||||
createMentionSuggestion({
|
||||
searchModels: suggestionModels,
|
||||
canBrowseAll: false,
|
||||
}),
|
||||
),
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
extensions,
|
||||
content: value,
|
||||
autofocus: autoFocus,
|
||||
immediatelyRender: false,
|
||||
onUpdate: ({ editor }) => {
|
||||
const jsonContent = editor.getJSON();
|
||||
serializedRef.current = serializeTiptapToMetabotMessage(jsonContent);
|
||||
onChangeRef.current(serializedRef.current);
|
||||
},
|
||||
editorProps: {
|
||||
handleDOMEvents: {
|
||||
copy(view, e) {
|
||||
e.preventDefault();
|
||||
const { from, to } = view.state.selection;
|
||||
const slice = view.state.doc.slice(from, to);
|
||||
const doc = view.state.schema.topNodeType.create(
|
||||
null,
|
||||
slice.content,
|
||||
);
|
||||
const serialized = serializeTiptapToMetabotMessage(doc.toJSON());
|
||||
e.clipboardData?.setData("text/plain", serialized);
|
||||
return true;
|
||||
},
|
||||
cut(view, e) {
|
||||
e.preventDefault();
|
||||
const { from, to } = view.state.selection;
|
||||
const slice = view.state.doc.slice(from, to);
|
||||
const doc = view.state.schema.topNodeType.create(
|
||||
null,
|
||||
slice.content,
|
||||
);
|
||||
const serialized = serializeTiptapToMetabotMessage(doc.toJSON());
|
||||
e.clipboardData?.setData("text/plain", serialized);
|
||||
view.dispatch(view.state.tr.deleteSelection());
|
||||
return true;
|
||||
},
|
||||
},
|
||||
handleKeyDown: (view, event) => {
|
||||
if (event.key === "Enter") {
|
||||
// Defer enter handling to mention UI if open
|
||||
const mentionState = MetabotMentionPluginKey.getState(view.state);
|
||||
if (mentionState?.active) {
|
||||
return false; // Let the suggestion system handle it
|
||||
}
|
||||
|
||||
// Check for any modifier keys (shift, ctrl, meta, alt)
|
||||
const isModifiedKeyPress =
|
||||
event.shiftKey ||
|
||||
event.ctrlKey ||
|
||||
event.metaKey ||
|
||||
event.altKey;
|
||||
|
||||
if (!isModifiedKeyPress) {
|
||||
event.preventDefault();
|
||||
onSubmitRef.current();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
clipboardTextSerializer: (content) => {
|
||||
return serializeTiptapToMetabotMessage(content.toJSON());
|
||||
},
|
||||
},
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return Object.assign(editor, {
|
||||
focus: () => editor.commands.focus("end"),
|
||||
get scrollHeight() {
|
||||
return editor.view.dom.scrollHeight;
|
||||
},
|
||||
get scrollTop() {
|
||||
return editor.view.dom.scrollTop;
|
||||
},
|
||||
});
|
||||
}, [editor]);
|
||||
|
||||
// Sync external value changes to editor
|
||||
useEffect(() => {
|
||||
if (value !== serializedRef.current) {
|
||||
editor?.commands.setContent(parseMetabotMessageToTiptapDoc(value));
|
||||
}
|
||||
}, [editor, value]);
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={S.editorContainer}>
|
||||
<Box className={S.iconContainer}>
|
||||
<Icon name="metabot" c="brand" />
|
||||
</Box>
|
||||
<Box className={S.contentWrapper}>
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
className={cx(S.content, {
|
||||
[S.disabled]: disabled,
|
||||
})}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// @ts-expect-error - must set a displayName
|
||||
MetabotChatEditor.displayName = "MetabotChatEditor";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./MetabotChatEditor";
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { JSONContent } from "@tiptap/core";
|
||||
import { match } from "ts-pattern";
|
||||
|
||||
import {
|
||||
METABSE_PROTOCOL_MD_LINK,
|
||||
createMetabaseProtocolLink,
|
||||
} from "metabase-enterprise/metabot/utils/links";
|
||||
|
||||
function serializeNodes(nodes: JSONContent[]): string {
|
||||
return nodes.map((node) => serializeNode(node)).join("");
|
||||
}
|
||||
|
||||
function serializeNode(node: JSONContent): string {
|
||||
switch (node.type) {
|
||||
case "paragraph":
|
||||
return serializeNodes(node.content ?? []);
|
||||
case "text":
|
||||
return node.text || "";
|
||||
case "smartLink": {
|
||||
const { entityId, model: entityModel, label } = node.attrs || {};
|
||||
if (!entityId || !entityModel) {
|
||||
console.warn("SmartLink missing required attributes", node);
|
||||
return label || "[unknown]";
|
||||
}
|
||||
return createMetabaseProtocolLink({
|
||||
id: entityId,
|
||||
model: match(entityModel)
|
||||
.with("dataset", () => "model" as const)
|
||||
.with("card", () => "question" as const)
|
||||
.otherwise(() => entityModel),
|
||||
name: label,
|
||||
});
|
||||
}
|
||||
case "hardBreak":
|
||||
return "\n";
|
||||
default:
|
||||
console.warn("Unhandled node type", node);
|
||||
return node.content ? serializeNodes(node.content) : node.text || "";
|
||||
}
|
||||
}
|
||||
|
||||
export function serializeTiptapToMetabotMessage(content: JSONContent): string {
|
||||
return serializeNodes(content?.content || []);
|
||||
}
|
||||
|
||||
const MENTION_REGEX = new RegExp(METABSE_PROTOCOL_MD_LINK, "g");
|
||||
const MODEL_MAP: Record<string, string | undefined> = {
|
||||
model: "dataset",
|
||||
question: "card",
|
||||
};
|
||||
|
||||
export function parseMetabotMessageToTiptapDoc(text: string): JSONContent {
|
||||
// Split text by lines to handle paragraphs
|
||||
const content = text.split("\n").map<JSONContent>((line) => {
|
||||
const pContent: JSONContent[] = [];
|
||||
let lastIndex = 0;
|
||||
|
||||
for (const match of line.matchAll(MENTION_REGEX)) {
|
||||
const [fullMatch, label, internalModel, entityId] = match;
|
||||
const model = MODEL_MAP[internalModel] || internalModel;
|
||||
|
||||
// Add text before the mention
|
||||
if (match.index > lastIndex) {
|
||||
pContent.push({
|
||||
type: "text",
|
||||
text: line.slice(lastIndex, match.index),
|
||||
});
|
||||
}
|
||||
|
||||
pContent.push({
|
||||
type: "smartLink",
|
||||
attrs: { label, model, entityId },
|
||||
});
|
||||
|
||||
lastIndex = match.index + fullMatch.length;
|
||||
}
|
||||
|
||||
// Add remaining text after last mention
|
||||
if (lastIndex < line.length) {
|
||||
pContent.push({
|
||||
type: "text",
|
||||
text: line.slice(lastIndex),
|
||||
});
|
||||
}
|
||||
|
||||
return { type: "paragraph", content: pContent };
|
||||
});
|
||||
|
||||
return {
|
||||
type: "doc",
|
||||
content:
|
||||
content.length > 0 ? content : [{ type: "paragraph", content: [] }],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { JSONContent } from "@tiptap/core";
|
||||
|
||||
import {
|
||||
parseMetabotMessageToTiptapDoc,
|
||||
serializeTiptapToMetabotMessage,
|
||||
} from "./utils";
|
||||
|
||||
describe("MetabotChatEditor > utils", () => {
|
||||
describe("serializeTiptapToMetabotMessage", () => {
|
||||
it("should serialize smark links to metabot protocol links", () => {
|
||||
const input: JSONContent = {
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "smartLink",
|
||||
attrs: { label: "test", model: "table", entityId: "1" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = serializeTiptapToMetabotMessage(input);
|
||||
|
||||
expect(result).toBe("[test](metabase://table/1)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseMetabotMessageToTiptapDoc", () => {
|
||||
it("should parse metabot protocol links", () => {
|
||||
const result = parseMetabotMessageToTiptapDoc(
|
||||
"[test](metabase://table/1)",
|
||||
);
|
||||
expect(result).toEqual({
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "smartLink",
|
||||
attrs: { label: "test", model: "table", entityId: "1" },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -13,13 +13,18 @@ import {
|
||||
Text,
|
||||
} from "metabase/ui";
|
||||
import type {
|
||||
MetabotAgentChatMessage,
|
||||
MetabotAgentTextChatMessage,
|
||||
MetabotChatMessage,
|
||||
MetabotErrorMessage,
|
||||
MetabotUserChatMessage,
|
||||
} from "metabase-enterprise/metabot/state";
|
||||
import type { MetabotFeedback } from "metabase-types/api";
|
||||
|
||||
import { AIMarkdown } from "../AIMarkdown/AIMarkdown";
|
||||
|
||||
import { AgentSuggestionMessage } from "./MetabotAgentSuggestionMessage";
|
||||
import { AgentTodoListMessage } from "./MetabotAgentTodoMessage";
|
||||
import Styles from "./MetabotChat.module.css";
|
||||
import { MetabotFeedbackModal } from "./MetabotFeedbackModal";
|
||||
|
||||
@@ -49,7 +54,8 @@ export const MessageContainer = ({
|
||||
/>
|
||||
);
|
||||
|
||||
interface UserMessageProps extends BaseMessageProps {
|
||||
interface UserMessageProps extends Omit<BaseMessageProps, "message"> {
|
||||
message: MetabotUserChatMessage;
|
||||
onCopy: () => void;
|
||||
}
|
||||
|
||||
@@ -61,14 +67,22 @@ export const UserMessage = ({
|
||||
...props
|
||||
}: UserMessageProps) => (
|
||||
<MessageContainer chatRole={message.role} {...props}>
|
||||
<Text
|
||||
className={cx(
|
||||
Styles.message,
|
||||
message.role === "user" && Styles.messageUser,
|
||||
)}
|
||||
>
|
||||
{message.message}
|
||||
</Text>
|
||||
{message.type === "text" && (
|
||||
<AIMarkdown className={cx(Styles.message, Styles.messageUser)}>
|
||||
{message.message}
|
||||
</AIMarkdown>
|
||||
)}
|
||||
|
||||
{message.type === "action" && (
|
||||
<Flex direction="column" gap="xs">
|
||||
<Flex align="center" gap="xs">
|
||||
<Text className={cx(Styles.message, Styles.messageUserAction)}>
|
||||
{message.userMessage}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
<Flex className={Styles.messageActions}>
|
||||
{!hideActions && (
|
||||
<ActionIcon
|
||||
@@ -104,8 +118,9 @@ const FeedbackButton = ({
|
||||
</ActionIcon>
|
||||
);
|
||||
|
||||
interface AgentMessageProps extends BaseMessageProps {
|
||||
onRetry: (messageId: string) => void;
|
||||
interface AgentMessageProps extends Omit<BaseMessageProps, "message"> {
|
||||
message: MetabotAgentChatMessage;
|
||||
onRetry?: (messageId: string) => void;
|
||||
onCopy: (messageId: string) => void;
|
||||
showFeedbackButtons: boolean;
|
||||
setFeedbackMessage?: (data: { messageId: string; positive: boolean }) => void;
|
||||
@@ -124,66 +139,77 @@ export const AgentMessage = ({
|
||||
onInternalLinkClick,
|
||||
hideActions,
|
||||
...props
|
||||
}: AgentMessageProps) => (
|
||||
<MessageContainer chatRole={message.role} {...props}>
|
||||
<AIMarkdown
|
||||
className={Styles.message}
|
||||
onInternalLinkClick={onInternalLinkClick}
|
||||
>
|
||||
{message.message}
|
||||
</AIMarkdown>
|
||||
|
||||
<Flex className={Styles.messageActions}>
|
||||
{!hideActions && (
|
||||
<>
|
||||
<ActionIcon
|
||||
h="sm"
|
||||
data-testid="metabot-chat-message-copy"
|
||||
onClick={() => onCopy(message.id)}
|
||||
>
|
||||
<Icon name="copy" size="1rem" />
|
||||
</ActionIcon>
|
||||
{showFeedbackButtons && setFeedbackMessage && (
|
||||
<>
|
||||
<FeedbackButton
|
||||
data-testid="metabot-chat-message-thumbs-up"
|
||||
icon="thumbs_up"
|
||||
hasBeenClicked={submittedFeedback === "positive"}
|
||||
disabled={!!submittedFeedback}
|
||||
onClick={() =>
|
||||
setFeedbackMessage({
|
||||
messageId: message.id,
|
||||
positive: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<FeedbackButton
|
||||
data-testid="metabot-chat-message-thumbs-down"
|
||||
icon="thumbs_down"
|
||||
hasBeenClicked={submittedFeedback === "negative"}
|
||||
disabled={!!submittedFeedback}
|
||||
onClick={() =>
|
||||
setFeedbackMessage({
|
||||
messageId: message.id,
|
||||
positive: false,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<ActionIcon
|
||||
onClick={() => onRetry(message.id)}
|
||||
h="sm"
|
||||
data-testid="metabot-chat-message-retry"
|
||||
>
|
||||
<Icon name="revert" size="1rem" />
|
||||
</ActionIcon>
|
||||
</>
|
||||
}: AgentMessageProps) => {
|
||||
return (
|
||||
<MessageContainer chatRole={message.role} {...props}>
|
||||
{message.type === "text" && (
|
||||
<AIMarkdown
|
||||
className={Styles.message}
|
||||
onInternalLinkClick={onInternalLinkClick}
|
||||
>
|
||||
{message.message}
|
||||
</AIMarkdown>
|
||||
)}
|
||||
</Flex>
|
||||
</MessageContainer>
|
||||
);
|
||||
{message.type === "edit_suggestion" && (
|
||||
<AgentSuggestionMessage message={message} />
|
||||
)}
|
||||
{message.type === "todo_list" && (
|
||||
<AgentTodoListMessage todos={message.payload} />
|
||||
)}
|
||||
<Flex className={Styles.messageActions}>
|
||||
{!hideActions && (
|
||||
<>
|
||||
<ActionIcon
|
||||
h="sm"
|
||||
data-testid="metabot-chat-message-copy"
|
||||
onClick={() => onCopy(message.id)}
|
||||
>
|
||||
<Icon name="copy" size="1rem" />
|
||||
</ActionIcon>
|
||||
{showFeedbackButtons && setFeedbackMessage && (
|
||||
<>
|
||||
<FeedbackButton
|
||||
data-testid="metabot-chat-message-thumbs-up"
|
||||
icon="thumbs_up"
|
||||
hasBeenClicked={submittedFeedback === "positive"}
|
||||
disabled={!!submittedFeedback}
|
||||
onClick={() =>
|
||||
setFeedbackMessage({
|
||||
messageId: message.id,
|
||||
positive: true,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<FeedbackButton
|
||||
data-testid="metabot-chat-message-thumbs-down"
|
||||
icon="thumbs_down"
|
||||
hasBeenClicked={submittedFeedback === "negative"}
|
||||
disabled={!!submittedFeedback}
|
||||
onClick={() =>
|
||||
setFeedbackMessage({
|
||||
messageId: message.id,
|
||||
positive: false,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{onRetry && (
|
||||
<ActionIcon
|
||||
onClick={() => onRetry(message.id)}
|
||||
h="sm"
|
||||
data-testid="metabot-chat-message-retry"
|
||||
>
|
||||
<Icon name="revert" size="1rem" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</MessageContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export const AgentErrorMessage = ({
|
||||
message,
|
||||
@@ -244,7 +270,7 @@ export const Messages = ({
|
||||
}: {
|
||||
messages: MetabotChatMessage[];
|
||||
errorMessages: MetabotErrorMessage[];
|
||||
onRetryMessage: (messageId: string) => void;
|
||||
onRetryMessage?: (messageId: string) => void;
|
||||
isDoingScience: boolean;
|
||||
showFeedbackButtons: boolean;
|
||||
onInternalLinkClick?: (navigateToPath: string) => void;
|
||||
@@ -278,7 +304,11 @@ export const Messages = ({
|
||||
const onAgentMessageCopy = useCallback(
|
||||
(messageId: string) => {
|
||||
const allMessages = getFullAgentReply(messages, messageId);
|
||||
clipboard.copy(allMessages.map((msg) => msg.message).join("\n\n"));
|
||||
const textMessages = allMessages.filter(
|
||||
(msg): msg is MetabotAgentTextChatMessage =>
|
||||
msg.role === "agent" && msg.type === "text",
|
||||
);
|
||||
clipboard.copy(textMessages.map((msg) => msg.message).join("\n\n"));
|
||||
},
|
||||
[messages, clipboard],
|
||||
);
|
||||
@@ -299,7 +329,7 @@ export const Messages = ({
|
||||
{messages.map((message, index) =>
|
||||
message.role === "agent" ? (
|
||||
<AgentMessage
|
||||
key={"msg-" + index}
|
||||
key={"msg-" + message.id}
|
||||
data-testid="metabot-chat-message"
|
||||
message={message}
|
||||
onRetry={onRetryMessage}
|
||||
@@ -307,16 +337,24 @@ export const Messages = ({
|
||||
showFeedbackButtons={showFeedbackButtons}
|
||||
setFeedbackMessage={setFeedbackModal}
|
||||
submittedFeedback={feedbackState.submitted[message.id]}
|
||||
hideActions={messages[index + 1]?.role === "agent"}
|
||||
hideActions={
|
||||
isDoingScience || messages[index + 1]?.role === "agent"
|
||||
}
|
||||
onInternalLinkClick={onInternalLinkClick}
|
||||
/>
|
||||
) : (
|
||||
<UserMessage
|
||||
key={"msg-" + index}
|
||||
key={"msg-" + message.id}
|
||||
data-testid="metabot-chat-message"
|
||||
message={message}
|
||||
hideActions={isDoingScience && messages.length === index + 1}
|
||||
onCopy={() => clipboard.copy(message.message)}
|
||||
onCopy={() => {
|
||||
const copyText =
|
||||
message.type === "action"
|
||||
? `${message.userMessage}: ${message.message}`
|
||||
: message.message;
|
||||
clipboard.copy(copyText);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
@@ -22,9 +22,9 @@ const ThoughtProcess = ({ toolCalls }: { toolCalls: MetabotToolCall[] }) => {
|
||||
const slots = new Array(toolCalls.length + 2).fill(null);
|
||||
|
||||
return (
|
||||
<Stack gap={0} className={S.toolCalls}>
|
||||
<Stack gap={0} mb="sm" className={S.toolCalls}>
|
||||
{slots.map((_, i) => {
|
||||
const tc = toolCalls[i];
|
||||
const tc: MetabotToolCall | undefined = toolCalls[i];
|
||||
return (
|
||||
<Transition
|
||||
key={i}
|
||||
@@ -35,9 +35,9 @@ const ThoughtProcess = ({ toolCalls }: { toolCalls: MetabotToolCall[] }) => {
|
||||
{(styles) => (
|
||||
<Text
|
||||
style={styles}
|
||||
className={cx(tc.status === "started" && S.toolCallStarted)}
|
||||
className={cx(tc?.status === "started" && S.toolCallStarted)}
|
||||
>
|
||||
{tc.message}
|
||||
{tc?.message}
|
||||
</Text>
|
||||
)}
|
||||
</Transition>
|
||||
@@ -49,30 +49,22 @@ const ThoughtProcess = ({ toolCalls }: { toolCalls: MetabotToolCall[] }) => {
|
||||
|
||||
export const MetabotThinking = ({
|
||||
toolCalls,
|
||||
hasStartedResponse,
|
||||
}: {
|
||||
toolCalls: MetabotToolCall[];
|
||||
hasStartedResponse: boolean;
|
||||
}) => {
|
||||
const toolCallsWithMsgs = useMemo(() => {
|
||||
return toolCalls.filter((tc) => !!tc.message);
|
||||
}, [toolCalls]);
|
||||
|
||||
const showLoader =
|
||||
(!hasStartedResponse && toolCalls.length === 0) ||
|
||||
(toolCalls.length > 0 && toolCallsWithMsgs.length === 0);
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<ThoughtProcess toolCalls={toolCallsWithMsgs} />
|
||||
{showLoader && (
|
||||
<Loader
|
||||
color="brand"
|
||||
type="dots"
|
||||
size="lg"
|
||||
data-testid="metabot-response-loader"
|
||||
/>
|
||||
)}
|
||||
<Loader
|
||||
color="brand"
|
||||
type="dots"
|
||||
size="lg"
|
||||
data-testid="metabot-response-loader"
|
||||
/>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -48,4 +48,28 @@ export const TOOL_CALL_MESSAGES: Record<string, string | undefined> = {
|
||||
get search_metabase_documentation() {
|
||||
return t`Consulting the docs`;
|
||||
},
|
||||
get write_transform_python() {
|
||||
return t`Writing Python`;
|
||||
},
|
||||
get write_transform_sql() {
|
||||
return t`Writing SQL`;
|
||||
},
|
||||
get todo_write() {
|
||||
return t`Planning`;
|
||||
},
|
||||
get todo_read() {
|
||||
return t`Planning`;
|
||||
},
|
||||
get search_transforms() {
|
||||
return t`Searching transforms`;
|
||||
},
|
||||
get get_transform_details() {
|
||||
return t`Getting transform details`;
|
||||
},
|
||||
get get_field_values() {
|
||||
return t`Retrieving table metadata`;
|
||||
},
|
||||
get search_tables() {
|
||||
return t`Searching database tables`;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useLazyListDatabasesQuery } from "metabase/api";
|
||||
import { useStore } from "metabase/lib/redux";
|
||||
import type {
|
||||
ChatContextProviderFn,
|
||||
MetabotChatInputRef,
|
||||
MetabotContext as MetabotCtx,
|
||||
} from "metabase/metabot";
|
||||
import { getHasDataAccess, getHasNativeWrite } from "metabase/selectors/data";
|
||||
@@ -26,6 +27,8 @@ export const defaultContext = {
|
||||
|
||||
export const MetabotContext = createContext<MetabotCtx>(defaultContext);
|
||||
|
||||
export type MetabotPromptInputRef = { focus: () => void };
|
||||
|
||||
export const MetabotProvider = ({
|
||||
children,
|
||||
}: {
|
||||
@@ -33,7 +36,7 @@ export const MetabotProvider = ({
|
||||
}) => {
|
||||
/* Metabot input */
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const promptInputRef = useRef<HTMLTextAreaElement>(null);
|
||||
const promptInputRef = useRef<MetabotChatInputRef>(null);
|
||||
|
||||
/* Metabot context */
|
||||
const providerFnsRef = useRef<Set<ChatContextProviderFn>>(new Set());
|
||||
|
||||
@@ -7,12 +7,13 @@ import { useMetabotContext } from "metabase/metabot";
|
||||
import { trackMetabotRequestSent } from "../analytics";
|
||||
import {
|
||||
type MetabotPromptSubmissionResult,
|
||||
type MetabotUserChatMessage,
|
||||
getAgentErrorMessages,
|
||||
getIsLongMetabotConversation,
|
||||
getIsProcessing,
|
||||
getLastAgentMessagesByType,
|
||||
getMessages,
|
||||
getMetabotId,
|
||||
getMetabotReactionsState,
|
||||
getMetabotRequestId,
|
||||
getMetabotVisible,
|
||||
getProfileOverride,
|
||||
@@ -36,9 +37,6 @@ export const useMetabotAgent = () => {
|
||||
const messages = useSelector(getMessages as any) as ReturnType<
|
||||
typeof getMessages
|
||||
>;
|
||||
const lastAgentMessages = useSelector(
|
||||
getLastAgentMessagesByType as any,
|
||||
) as ReturnType<typeof getLastAgentMessagesByType>;
|
||||
|
||||
const errorMessages = useSelector(getAgentErrorMessages as any) as ReturnType<
|
||||
typeof getAgentErrorMessages
|
||||
@@ -68,10 +66,14 @@ export const useMetabotAgent = () => {
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const profile = useSelector(getProfileOverride as any) as ReturnType<
|
||||
const profileOverride = useSelector(getProfileOverride as any) as ReturnType<
|
||||
typeof getProfileOverride
|
||||
>;
|
||||
|
||||
const reactions = useSelector(getMetabotReactionsState as any) as ReturnType<
|
||||
typeof getMetabotReactionsState
|
||||
>;
|
||||
|
||||
const resetConversation = useCallback(
|
||||
() => dispatch(resetConversationAction()),
|
||||
[dispatch],
|
||||
@@ -88,18 +90,27 @@ export const useMetabotAgent = () => {
|
||||
);
|
||||
|
||||
const submitInput = useCallback(
|
||||
async (prompt: string) => {
|
||||
async (prompt: string | Omit<MetabotUserChatMessage, "id" | "role">) => {
|
||||
if (!visible) {
|
||||
setVisible(true);
|
||||
}
|
||||
|
||||
const context = await getChatContext();
|
||||
const action = await dispatch(
|
||||
submitInputAction({
|
||||
message: prompt,
|
||||
context,
|
||||
metabot_id: metabotRequestId,
|
||||
}),
|
||||
submitInputAction(
|
||||
typeof prompt === "string"
|
||||
? {
|
||||
type: "text",
|
||||
message: prompt,
|
||||
context,
|
||||
metabot_id: metabotRequestId,
|
||||
}
|
||||
: {
|
||||
...prompt,
|
||||
context,
|
||||
metabot_id: metabotRequestId,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
trackMetabotRequestSent();
|
||||
@@ -157,7 +168,6 @@ export const useMetabotAgent = () => {
|
||||
metabotId,
|
||||
visible,
|
||||
messages,
|
||||
lastAgentMessages,
|
||||
errorMessages,
|
||||
isLongConversation,
|
||||
resetConversation,
|
||||
@@ -166,6 +176,7 @@ export const useMetabotAgent = () => {
|
||||
submitInput,
|
||||
retryMessage,
|
||||
toolCalls,
|
||||
profile,
|
||||
profileOverride,
|
||||
reactions,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { useCallback } from "react";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
import type { MetabotConfig } from "../components/Metabot";
|
||||
|
||||
import { useMetabotAgent } from "./use-metabot-agent";
|
||||
|
||||
export const useMetabotChatHandlers = () => {
|
||||
export const useMetabotChatHandlers = (
|
||||
config?: Partial<Pick<MetabotConfig, "preventRetryMessage">>,
|
||||
) => {
|
||||
const {
|
||||
isDoingScience,
|
||||
setPrompt,
|
||||
@@ -28,8 +32,12 @@ export const useMetabotChatHandlers = () => {
|
||||
[isDoingScience, promptInputRef, setPrompt, submitInput],
|
||||
);
|
||||
|
||||
const handleRetryMessage = useCallback(
|
||||
(messageId: string) => {
|
||||
const handleRetryMessage = useMemo(() => {
|
||||
if (config?.preventRetryMessage) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return (messageId: string) => {
|
||||
if (isDoingScience) {
|
||||
return;
|
||||
}
|
||||
@@ -37,9 +45,14 @@ export const useMetabotChatHandlers = () => {
|
||||
setPrompt("");
|
||||
promptInputRef?.current?.focus();
|
||||
retryMessage(messageId).catch((err) => console.error(err));
|
||||
},
|
||||
[isDoingScience, promptInputRef, retryMessage, setPrompt],
|
||||
);
|
||||
};
|
||||
}, [
|
||||
config?.preventRetryMessage,
|
||||
isDoingScience,
|
||||
promptInputRef,
|
||||
retryMessage,
|
||||
setPrompt,
|
||||
]);
|
||||
|
||||
const handleResetInput = useCallback(() => {
|
||||
setPrompt("");
|
||||
|
||||
@@ -986,7 +986,9 @@ describe("metabot-streaming", () => {
|
||||
|
||||
// adding messages this long via the ui's input makes the test hang
|
||||
act(() => {
|
||||
store.dispatch(addUserMessage({ id: "1", message: longMsg }));
|
||||
store.dispatch(
|
||||
addUserMessage({ id: "1", type: "text", message: longMsg }),
|
||||
);
|
||||
});
|
||||
expect(await screen.findByText(/xxxxxxx/)).toBeInTheDocument();
|
||||
expect(
|
||||
@@ -994,7 +996,9 @@ describe("metabot-streaming", () => {
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
store.dispatch(addUserMessage({ id: "2", message: longMsg }));
|
||||
store.dispatch(
|
||||
addUserMessage({ id: "2", type: "text", message: longMsg }),
|
||||
);
|
||||
});
|
||||
expect(
|
||||
await screen.findByText(/This chat is getting long/),
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { addAgentErrorMessage, stopProcessing } from "../state";
|
||||
|
||||
import type { ReactionHandler } from "./types";
|
||||
|
||||
type UnknownReaction = { type: string } & Record<string, unknown>;
|
||||
|
||||
export const notifyUnknownReaction: ReactionHandler<UnknownReaction> = (
|
||||
reaction,
|
||||
) => {
|
||||
return ({ dispatch }) => {
|
||||
console.error("Unknown reaction recieved", reaction);
|
||||
dispatch(
|
||||
addAgentErrorMessage({
|
||||
type: "message",
|
||||
message: t`Whoops, I actually can’t do this. Sorry.`,
|
||||
}),
|
||||
);
|
||||
dispatch(stopProcessing());
|
||||
};
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { MetabotReaction } from "metabase-types/api";
|
||||
|
||||
import { showMessage } from "./messages";
|
||||
import { redirect } from "./metabot";
|
||||
import type { ReactionHandler } from "./types";
|
||||
|
||||
export * from "./errors";
|
||||
|
||||
type ReactionHandlers = {
|
||||
[key in MetabotReaction["type"]]: ReactionHandler<
|
||||
Extract<MetabotReaction, { type: key }>
|
||||
>;
|
||||
};
|
||||
|
||||
export const reactionHandlers: ReactionHandlers = {
|
||||
"metabot.reaction/message": showMessage,
|
||||
"metabot.reaction/redirect": redirect,
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { MetabotMessageReaction } from "metabase-types/api";
|
||||
|
||||
import { addAgentMessage } from "../state";
|
||||
|
||||
import type { ReactionHandler } from "./types";
|
||||
|
||||
export const showMessage: ReactionHandler<MetabotMessageReaction> = (
|
||||
reaction,
|
||||
) => {
|
||||
return ({ dispatch }) => {
|
||||
dispatch(addAgentMessage({ message: reaction.message }));
|
||||
};
|
||||
};
|
||||
@@ -1,21 +0,0 @@
|
||||
import { push } from "react-router-redux";
|
||||
|
||||
import type { MetabotRedirectReaction } from "metabase-types/api";
|
||||
|
||||
import type { ReactionHandler } from "./types";
|
||||
|
||||
export const redirect: ReactionHandler<MetabotRedirectReaction> = (
|
||||
reaction,
|
||||
) => {
|
||||
const redirectUrl = new URL(`${window.location.origin}${reaction.url}`);
|
||||
|
||||
return async ({ dispatch }) => {
|
||||
await dispatch(
|
||||
push({
|
||||
pathname: redirectUrl.pathname,
|
||||
hash: redirectUrl.hash,
|
||||
search: redirectUrl.search,
|
||||
}),
|
||||
);
|
||||
};
|
||||
};
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { Dispatch, GetState } from "metabase-types/store";
|
||||
|
||||
export type ReactionHandler<Reaction> = (
|
||||
reaction: Reaction,
|
||||
) => (reduxApis: {
|
||||
dispatch: Dispatch;
|
||||
getState: GetState;
|
||||
}) => void | Promise<void>;
|
||||
@@ -16,14 +16,19 @@ import type {
|
||||
MetabotAgentRequest,
|
||||
MetabotAgentResponse,
|
||||
MetabotChatContext,
|
||||
MetabotReaction,
|
||||
MetabotTransformInfo,
|
||||
} from "metabase-types/api";
|
||||
import type { Dispatch } from "metabase-types/store";
|
||||
|
||||
import { METABOT_ERR_MSG } from "../constants";
|
||||
import { notifyUnknownReaction, reactionHandlers } from "../reactions";
|
||||
|
||||
import { type MetabotErrorMessage, metabot } from "./reducer";
|
||||
import {
|
||||
type MetabotAgentEditSuggestionChatMessage,
|
||||
type MetabotAgentTodoListChatMessage,
|
||||
type MetabotErrorMessage,
|
||||
type MetabotUserChatMessage,
|
||||
metabot,
|
||||
} from "./reducer";
|
||||
import {
|
||||
getAgentErrorMessages,
|
||||
getAgentRequestMetadata,
|
||||
@@ -48,6 +53,7 @@ export const {
|
||||
toolCallEnd,
|
||||
setProfileOverride,
|
||||
setMetabotReqIdOverride,
|
||||
setSuggestedTransform,
|
||||
} = metabot.actions;
|
||||
|
||||
type PromptErrorOutcome = {
|
||||
@@ -125,8 +131,7 @@ export type MetabotPromptSubmissionResult =
|
||||
|
||||
export const submitInput = createAsyncThunk<
|
||||
MetabotPromptSubmissionResult,
|
||||
{
|
||||
message: string;
|
||||
Omit<MetabotUserChatMessage, "id" | "role"> & {
|
||||
context: MetabotChatContext;
|
||||
metabot_id?: string;
|
||||
}
|
||||
@@ -158,7 +163,7 @@ export const submitInput = createAsyncThunk<
|
||||
// altering it by adding the current message the user is wanting to send
|
||||
const agentMetadata = getAgentRequestMetadata(getState() as any);
|
||||
const messageId = createMessageId();
|
||||
dispatch(addUserMessage({ id: messageId, message: data.message }));
|
||||
dispatch(addUserMessage({ id: messageId, ...data }));
|
||||
|
||||
const sendMessageRequestPromise = dispatch(
|
||||
sendAgentRequest({
|
||||
@@ -229,10 +234,21 @@ export const sendAgentRequest = createAsyncThunk<
|
||||
signal,
|
||||
},
|
||||
{
|
||||
onDataPart: (part) => {
|
||||
onDataPart: function handleDataPart(part) {
|
||||
match(part)
|
||||
// only update the convo state if the request is successful
|
||||
.with({ type: "state" }, (part) => (state = part.value))
|
||||
.with({ type: "todo_list" }, (part) => {
|
||||
const message: Omit<
|
||||
MetabotAgentTodoListChatMessage,
|
||||
"id" | "role"
|
||||
> = {
|
||||
type: "todo_list",
|
||||
payload: part.value,
|
||||
};
|
||||
|
||||
dispatch(addAgentMessage(message));
|
||||
})
|
||||
.with({ type: "navigate_to" }, (part) => {
|
||||
dispatch(setNavigateToPath(part.value));
|
||||
|
||||
@@ -240,14 +256,44 @@ export const sendAgentRequest = createAsyncThunk<
|
||||
dispatch(push(part.value) as UnknownAction);
|
||||
}
|
||||
})
|
||||
.with(
|
||||
{ type: "transform_suggestion" },
|
||||
({ value: suggestedTransform }) => {
|
||||
dispatch(setSuggestedTransform(suggestedTransform));
|
||||
|
||||
const transform = req.context.user_is_viewing
|
||||
.filter(
|
||||
(t): t is MetabotTransformInfo => t.type === "transform",
|
||||
)
|
||||
.find((t) => t.id === suggestedTransform.id);
|
||||
const message: Omit<
|
||||
MetabotAgentEditSuggestionChatMessage,
|
||||
"id" | "role"
|
||||
> = {
|
||||
type: "edit_suggestion",
|
||||
model: "transform",
|
||||
payload: {
|
||||
editorTransform: transform,
|
||||
suggestedTransform,
|
||||
},
|
||||
};
|
||||
dispatch(addAgentMessage(message));
|
||||
},
|
||||
)
|
||||
.exhaustive();
|
||||
},
|
||||
onTextPart: (part) => {
|
||||
onTextPart: function handleTextPart(part) {
|
||||
dispatch(addAgentTextDelta(String(part)));
|
||||
},
|
||||
onToolCallPart: (part) => dispatch(toolCallStart(part)),
|
||||
onToolResultPart: (part) => dispatch(toolCallEnd(part)),
|
||||
onError: (part) => (error = part),
|
||||
onToolCallPart: function handleToolCallPart(part) {
|
||||
dispatch(toolCallStart(part));
|
||||
},
|
||||
onToolResultPart: function handleToolResultPart(part) {
|
||||
dispatch(toolCallEnd(part));
|
||||
},
|
||||
onError: function handleError(part) {
|
||||
error = part;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -309,7 +355,12 @@ export const retryPrompt = createAsyncThunk<
|
||||
dispatch(metabot.actions.rewindStateToMessageId(messageId));
|
||||
|
||||
return await dispatch(
|
||||
submitInput({ message: prompt.message, context, metabot_id }),
|
||||
submitInput({
|
||||
type: "text",
|
||||
message: prompt.message,
|
||||
context,
|
||||
metabot_id,
|
||||
}),
|
||||
).unwrap();
|
||||
},
|
||||
);
|
||||
@@ -326,37 +377,6 @@ export const resetConversation = createAsyncThunk(
|
||||
},
|
||||
);
|
||||
|
||||
export const processMetabotReactions = createAsyncThunk(
|
||||
"metabase-enterprise/metabot/processMetabotReactions",
|
||||
async (reactions: MetabotReaction[], { dispatch, getState }) => {
|
||||
dispatch(setIsProcessing(true));
|
||||
|
||||
for (const reaction of reactions) {
|
||||
try {
|
||||
const reactionHandler =
|
||||
reactionHandlers[reaction.type] ?? notifyUnknownReaction;
|
||||
// TS isn't smart enough to know the reaction matches the handler
|
||||
await reactionHandler(reaction as any)({ dispatch, getState });
|
||||
} catch (error: any) {
|
||||
console.error("Halting processing of reactions.", error);
|
||||
dispatch(stopProcessingAndNotify());
|
||||
break;
|
||||
}
|
||||
|
||||
// TODO: make an EnterpriseStore
|
||||
const isProcessing = getIsProcessing(getState() as any);
|
||||
if (!isProcessing) {
|
||||
console.warn(
|
||||
"A handler has stopped further procesing of metabot reactions",
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(setIsProcessing(false));
|
||||
},
|
||||
);
|
||||
|
||||
export const stopProcessing = () => (dispatch: Dispatch) => {
|
||||
dispatch(setIsProcessing(false));
|
||||
};
|
||||
|
||||
@@ -3,19 +3,71 @@ import _ from "underscore";
|
||||
|
||||
import { logout } from "metabase/auth/actions";
|
||||
import { uuid } from "metabase/lib/uuid";
|
||||
import type { MetabotHistory } from "metabase-types/api";
|
||||
import type {
|
||||
MetabotHistory,
|
||||
MetabotTodoItem,
|
||||
MetabotTransformInfo,
|
||||
SuggestedTransform,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import { TOOL_CALL_MESSAGES } from "../constants";
|
||||
|
||||
import { sendAgentRequest } from "./actions";
|
||||
import { createMessageId } from "./utils";
|
||||
|
||||
export type MetabotChatMessage = {
|
||||
export type MetabotUserTextChatMessage = {
|
||||
id: string;
|
||||
role: "user" | "agent";
|
||||
role: "user";
|
||||
type: "text";
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type MetabotUserActionChatMessage = {
|
||||
id: string;
|
||||
role: "user";
|
||||
type: "action";
|
||||
message: string;
|
||||
userMessage: string;
|
||||
};
|
||||
|
||||
export type MetabotAgentTextChatMessage = {
|
||||
id: string;
|
||||
role: "agent";
|
||||
type: "text";
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type MetabotAgentTodoListChatMessage = {
|
||||
id: string;
|
||||
role: "agent";
|
||||
type: "todo_list";
|
||||
payload: MetabotTodoItem[];
|
||||
};
|
||||
|
||||
export type MetabotAgentEditSuggestionChatMessage = {
|
||||
id: string;
|
||||
role: "agent";
|
||||
type: "edit_suggestion";
|
||||
model: "transform";
|
||||
payload: {
|
||||
editorTransform: MetabotTransformInfo | undefined;
|
||||
suggestedTransform: SuggestedTransform;
|
||||
};
|
||||
};
|
||||
|
||||
export type MetabotAgentChatMessage =
|
||||
| MetabotAgentTextChatMessage
|
||||
| MetabotAgentTodoListChatMessage
|
||||
| MetabotAgentEditSuggestionChatMessage;
|
||||
|
||||
export type MetabotUserChatMessage =
|
||||
| MetabotUserTextChatMessage
|
||||
| MetabotUserActionChatMessage;
|
||||
|
||||
export type MetabotChatMessage =
|
||||
| MetabotUserChatMessage
|
||||
| MetabotAgentChatMessage;
|
||||
|
||||
export type MetabotErrorMessage = {
|
||||
type: "message" | "alert";
|
||||
message: string;
|
||||
@@ -30,6 +82,7 @@ export type MetabotToolCall = {
|
||||
|
||||
export type MetabotReactionsState = {
|
||||
navigateToPath: string | null;
|
||||
suggestedTransform: SuggestedTransform | undefined;
|
||||
};
|
||||
|
||||
export interface MetabotState {
|
||||
@@ -58,6 +111,7 @@ export const getMetabotInitialState = (): MetabotState => ({
|
||||
state: {},
|
||||
reactions: {
|
||||
navigateToPath: null,
|
||||
suggestedTransform: undefined,
|
||||
},
|
||||
toolCalls: [],
|
||||
experimental: {
|
||||
@@ -72,24 +126,27 @@ export const metabot = createSlice({
|
||||
reducers: {
|
||||
addUserMessage: (
|
||||
state,
|
||||
action: PayloadAction<Omit<MetabotChatMessage, "role">>,
|
||||
action: PayloadAction<Omit<MetabotUserChatMessage, "role">>,
|
||||
) => {
|
||||
const { id, message } = action.payload;
|
||||
const { id, message, ...rest } = action.payload;
|
||||
|
||||
state.errorMessages = [];
|
||||
state.messages.push({ id, role: "user", message });
|
||||
state.messages.push({ id, role: "user", message, ...rest } as any);
|
||||
state.history.push({ id, role: "user", content: message });
|
||||
},
|
||||
addAgentMessage: (
|
||||
state,
|
||||
action: PayloadAction<Omit<MetabotChatMessage, "id" | "role">>,
|
||||
action: PayloadAction<Omit<MetabotAgentChatMessage, "id" | "role">>,
|
||||
) => {
|
||||
state.toolCalls = [];
|
||||
state.messages.push({
|
||||
id: createMessageId(),
|
||||
role: "agent",
|
||||
message: action.payload.message,
|
||||
});
|
||||
...action.payload,
|
||||
// transforms in message is making this flakily produce possibly infinite
|
||||
// typescript errors. since unused ts-expect-error directives produces
|
||||
// errors, casting this as any to avoid having to add / remove constantly.
|
||||
} as any);
|
||||
},
|
||||
addAgentErrorMessage: (
|
||||
state,
|
||||
@@ -100,14 +157,18 @@ export const metabot = createSlice({
|
||||
addAgentTextDelta: (state, action: PayloadAction<string>) => {
|
||||
const hasToolCalls = state.toolCalls.length > 0;
|
||||
const lastMessage = _.last(state.messages);
|
||||
const canAppend = !hasToolCalls && lastMessage?.role === "agent";
|
||||
const canAppend =
|
||||
!hasToolCalls &&
|
||||
lastMessage?.role === "agent" &&
|
||||
lastMessage.type === "text";
|
||||
|
||||
if (canAppend) {
|
||||
lastMessage!.message = lastMessage!.message + action.payload;
|
||||
lastMessage.message = lastMessage.message + action.payload;
|
||||
} else {
|
||||
state.messages.push({
|
||||
id: createMessageId(),
|
||||
role: "agent",
|
||||
type: "text",
|
||||
message: action.payload,
|
||||
});
|
||||
}
|
||||
@@ -156,6 +217,7 @@ export const metabot = createSlice({
|
||||
state.isProcessing = false;
|
||||
state.toolCalls = [];
|
||||
state.conversationId = uuid();
|
||||
state.reactions.suggestedTransform = undefined;
|
||||
state.experimental.metabotReqIdOverride = undefined;
|
||||
},
|
||||
resetConversationId: (state) => {
|
||||
@@ -167,6 +229,15 @@ export const metabot = createSlice({
|
||||
setNavigateToPath: (state, action: PayloadAction<string>) => {
|
||||
state.reactions.navigateToPath = action.payload;
|
||||
},
|
||||
setSuggestedTransform: (
|
||||
state,
|
||||
action: PayloadAction<SuggestedTransform | undefined>,
|
||||
) => {
|
||||
// transform type makes this flakily produce possibly infinite
|
||||
// typescript errors. since unused ts-expect-error directives produces
|
||||
// errors, casting this as any to avoid having to add / remove constantly.
|
||||
state.reactions.suggestedTransform = action.payload as any;
|
||||
},
|
||||
setVisible: (state, action: PayloadAction<boolean>) => {
|
||||
state.visible = action.payload;
|
||||
},
|
||||
|
||||
@@ -2,6 +2,8 @@ import { createSelector } from "@reduxjs/toolkit";
|
||||
import _ from "underscore";
|
||||
|
||||
import { getIsEmbedding } from "metabase/selectors/embed";
|
||||
import { getLocation } from "metabase/selectors/routing";
|
||||
import type { TransformId } from "metabase-types/api";
|
||||
|
||||
import {
|
||||
FIXED_METABOT_IDS,
|
||||
@@ -9,6 +11,7 @@ import {
|
||||
METABOT_REQUEST_IDS,
|
||||
} from "../constants";
|
||||
|
||||
import type { MetabotUserChatMessage } from "./reducer";
|
||||
import type { MetabotStoreState } from "./types";
|
||||
|
||||
export const getMetabot = (state: MetabotStoreState) => {
|
||||
@@ -16,8 +19,9 @@ export const getMetabot = (state: MetabotStoreState) => {
|
||||
};
|
||||
|
||||
export const getMetabotVisible = createSelector(
|
||||
getMetabot,
|
||||
(metabot) => metabot.visible,
|
||||
[getMetabot, getLocation],
|
||||
(metabot, location) =>
|
||||
location.pathname.startsWith("/admin/transforms") ? true : metabot.visible,
|
||||
);
|
||||
|
||||
export const getMessages = createSelector(
|
||||
@@ -44,7 +48,7 @@ export const getAgentErrorMessages = createSelector(
|
||||
// that exact message will be returned.
|
||||
export const getUserPromptForMessageId = createSelector(
|
||||
[getMessages, (_, messageId: string) => messageId],
|
||||
(messages, messageId) => {
|
||||
(messages, messageId): MetabotUserChatMessage | undefined => {
|
||||
const messageIndex = messages.findLastIndex((m) => m.id === messageId);
|
||||
const message = messages[messageIndex];
|
||||
if (!message) {
|
||||
@@ -54,23 +58,13 @@ export const getUserPromptForMessageId = createSelector(
|
||||
if (message.role === "user") {
|
||||
return message;
|
||||
} else {
|
||||
return messages.slice(0, messageIndex).findLast((m) => m.role === "user");
|
||||
return messages
|
||||
.slice(0, messageIndex)
|
||||
.findLast<MetabotUserChatMessage>((m) => m.role === "user");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export const getLastAgentMessagesByType = createSelector(
|
||||
[getMessages, getAgentErrorMessages],
|
||||
(messages, errorMessages) => {
|
||||
if (errorMessages.length > 0) {
|
||||
return errorMessages.map(({ message }) => message);
|
||||
}
|
||||
|
||||
const start = messages.findLastIndex((msg) => msg.role !== "agent") + 1;
|
||||
return messages.slice(start).map(({ message }) => message);
|
||||
},
|
||||
);
|
||||
|
||||
export const getIsProcessing = createSelector(
|
||||
getMetabot,
|
||||
(metabot) => metabot.isProcessing,
|
||||
@@ -100,7 +94,7 @@ export const getIsLongMetabotConversation = createSelector(
|
||||
getMessages,
|
||||
(messages) => {
|
||||
const totalMessageLength = messages.reduce((sum, msg) => {
|
||||
return sum + msg.message.length;
|
||||
return sum + ("message" in msg ? msg.message.length : 0);
|
||||
}, 0);
|
||||
return totalMessageLength >= LONG_CONVO_MSG_LENGTH_THRESHOLD;
|
||||
},
|
||||
@@ -128,17 +122,31 @@ export const getProfileOverride = createSelector(
|
||||
(metabot) => metabot.experimental.profileOverride,
|
||||
);
|
||||
|
||||
export const getProfile = createSelector(
|
||||
getProfileOverride,
|
||||
getLocation,
|
||||
(profileOverride, location) => {
|
||||
if (profileOverride) {
|
||||
return profileOverride;
|
||||
}
|
||||
|
||||
return location.pathname.startsWith("/admin/transforms")
|
||||
? "transforms_codegen"
|
||||
: undefined;
|
||||
},
|
||||
);
|
||||
|
||||
export const getAgentRequestMetadata = createSelector(
|
||||
getHistory,
|
||||
getMetabotState,
|
||||
getProfileOverride,
|
||||
(history, state, profileId) => ({
|
||||
getProfile,
|
||||
(history, state, profile) => ({
|
||||
state,
|
||||
// NOTE: need end to end support for ids on messages as BE will error if ids are present
|
||||
history: history.map((h) =>
|
||||
h.id && h.id.startsWith(`msg_`) ? _.omit(h, "id") : h,
|
||||
),
|
||||
...(profileId ? { profile_id: profileId } : {}),
|
||||
...(profile ? { profile_id: profile } : {}),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -146,3 +154,12 @@ export const getNavigateToPath = createSelector(
|
||||
getMetabotReactionsState,
|
||||
(reactionsState) => reactionsState.navigateToPath,
|
||||
);
|
||||
|
||||
export const getMetabotSuggestedTransform = createSelector(
|
||||
[getMetabotReactionsState, (_, transformId?: TransformId) => transformId],
|
||||
(reactionsState, transformId) => {
|
||||
return reactionsState.suggestedTransform?.id === transformId
|
||||
? reactionsState.suggestedTransform
|
||||
: undefined;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -3,7 +3,6 @@ import { createMockState } from "metabase-types/store/mocks";
|
||||
|
||||
import {
|
||||
type MetabotState,
|
||||
getLastAgentMessagesByType,
|
||||
getMetabotInitialState,
|
||||
getUserPromptForMessageId,
|
||||
} from "./index";
|
||||
@@ -23,71 +22,41 @@ function setup(metabotState: Partial<MetabotState>) {
|
||||
}
|
||||
|
||||
describe("metabot selectors", () => {
|
||||
describe("getLastAgentMessagesByType", () => {
|
||||
it("should not return any messages if there are none", () => {
|
||||
const state = setup({ messages: [] });
|
||||
const messages = getLastAgentMessagesByType(state as any);
|
||||
expect(messages).toEqual([]);
|
||||
});
|
||||
|
||||
it("should not return any messages if latest message is from a user", () => {
|
||||
const state = setup({
|
||||
messages: [{ id: "1", role: "user", message: "bleh" }],
|
||||
});
|
||||
const messages = getLastAgentMessagesByType(state as any);
|
||||
expect(messages).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return latest agent reply messages only", () => {
|
||||
const state = setup({
|
||||
messages: [
|
||||
{ id: "1", role: "user", message: "bleh" },
|
||||
{ id: "2", role: "agent", message: "blah" },
|
||||
{ id: "3", role: "agent", message: "blah" },
|
||||
],
|
||||
});
|
||||
const messages = getLastAgentMessagesByType(state as any);
|
||||
expect(messages).toEqual(["blah", "blah"]);
|
||||
});
|
||||
|
||||
it("should return latest agent error messages only", () => {
|
||||
const state = setup({
|
||||
messages: [{ id: "1", role: "agent", message: "blah" }],
|
||||
errorMessages: [
|
||||
{ type: "message", message: "BLAH" },
|
||||
{ type: "message", message: "BLAH" },
|
||||
],
|
||||
});
|
||||
const messages = getLastAgentMessagesByType(state as any);
|
||||
expect(messages).toEqual(["BLAH", "BLAH"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getUserPromptForMessageId", () => {
|
||||
it("should return the message with the matching id if id is for a user message", () => {
|
||||
const state = setup({
|
||||
messages: [
|
||||
{ id: "1", role: "user", message: "bleh" },
|
||||
{ id: "2", role: "agent", message: "blah" },
|
||||
{ id: "1", role: "user", type: "text", message: "bleh" },
|
||||
{ id: "2", role: "agent", type: "text", message: "blah" },
|
||||
],
|
||||
});
|
||||
const message = getUserPromptForMessageId(state as any, "1");
|
||||
expect(message).toEqual({ id: "1", role: "user", message: "bleh" });
|
||||
expect(message).toEqual({
|
||||
id: "1",
|
||||
role: "user",
|
||||
type: "text",
|
||||
message: "bleh",
|
||||
});
|
||||
});
|
||||
|
||||
it("should return the message with the matching id if id is for an agent message", () => {
|
||||
const state = setup({
|
||||
messages: [
|
||||
{ id: "1", role: "user", message: "bleh" },
|
||||
{ id: "2", role: "agent", message: "blah" },
|
||||
{ id: "3", role: "user", message: "bleh bleh" },
|
||||
{ id: "4", role: "agent", message: "blah blah" },
|
||||
{ id: "1", type: "text", role: "user", message: "bleh" },
|
||||
{ id: "2", type: "text", role: "agent", message: "blah" },
|
||||
{ id: "3", type: "text", role: "user", message: "bleh bleh" },
|
||||
{ id: "4", type: "text", role: "agent", message: "blah blah" },
|
||||
],
|
||||
});
|
||||
const message1 = getUserPromptForMessageId(state as any, "2");
|
||||
expect(message1).toEqual({ id: "1", role: "user", message: "bleh" });
|
||||
const message2 = getUserPromptForMessageId(state as any, "4");
|
||||
expect(message2).toEqual({ id: "3", role: "user", message: "bleh bleh" });
|
||||
expect(message2).toEqual({
|
||||
id: "3",
|
||||
role: "user",
|
||||
type: "text",
|
||||
message: "bleh bleh",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
export const METABSE_PROTOCOL_LINK =
|
||||
/metabase:\/\/(?<model>[^\/]+)\/(?<id>\d+)/;
|
||||
|
||||
export const METABSE_PROTOCOL_MD_LINK =
|
||||
/\[(?<name>[^\]]+)\]\(metabase:\/\/(?<model>[^\/]+)\/(?<id>\d+)\)/;
|
||||
|
||||
export const METABASE_PROTOCOL_ENTITY_MODELS = [
|
||||
"question",
|
||||
"dashboard",
|
||||
"collection",
|
||||
"document",
|
||||
"model",
|
||||
"database",
|
||||
"table",
|
||||
"transform",
|
||||
] as const;
|
||||
|
||||
export type MetabaseProtocolEntityModel =
|
||||
(typeof METABASE_PROTOCOL_ENTITY_MODELS)[number];
|
||||
|
||||
const isMetabaseProtocolEntityModel = (
|
||||
model: string,
|
||||
): model is MetabaseProtocolEntityModel => {
|
||||
return (METABASE_PROTOCOL_ENTITY_MODELS as readonly string[]).includes(model);
|
||||
};
|
||||
|
||||
export interface MetabaseProtocolEntity {
|
||||
id: number;
|
||||
model: MetabaseProtocolEntityModel;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export const parseMetabaseProtocolLink = (
|
||||
href: string,
|
||||
): Pick<MetabaseProtocolEntity, "id" | "model"> | undefined => {
|
||||
const match = href.match(METABSE_PROTOCOL_LINK);
|
||||
|
||||
if (!match?.groups) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { id, model } = match?.groups;
|
||||
|
||||
if (!isMetabaseProtocolEntityModel(model)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { id: parseInt(id, 10), model };
|
||||
};
|
||||
|
||||
export const parseMetabaseProtocolMarkdownLink = (
|
||||
href: string,
|
||||
): MetabaseProtocolEntity | undefined => {
|
||||
const match = href.match(METABSE_PROTOCOL_MD_LINK);
|
||||
|
||||
if (!match?.groups) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { id, model, name } = match?.groups;
|
||||
|
||||
if (!isMetabaseProtocolEntityModel(model)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { id: parseInt(id, 10), model, name };
|
||||
};
|
||||
|
||||
export const createMetabaseProtocolLink = ({
|
||||
id,
|
||||
name,
|
||||
model,
|
||||
}: MetabaseProtocolEntity): string => {
|
||||
return `[${name}](metabase://${model}/${id})`;
|
||||
};
|
||||
@@ -231,6 +231,7 @@ export const CommandSuggestion = forwardRef<
|
||||
onSelectEntity: onSelectLinkEntity,
|
||||
enabled: true,
|
||||
searchModels: showLinkSearch ? LINK_SEARCH_MODELS : EMBED_SEARCH_MODELS,
|
||||
canBrowseAll: true,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -397,6 +398,7 @@ export const CommandSuggestion = forwardRef<
|
||||
modal={entityModal}
|
||||
onModalSelect={entityHandlers.handleModalSelect}
|
||||
onModalClose={entityHandlers.handleModalClose}
|
||||
canBrowseAll
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
|
||||
@@ -7,11 +7,11 @@ import {
|
||||
LoadingSuggestionPaper,
|
||||
SuggestionPaper,
|
||||
} from "metabase-enterprise/documents/components/Editor/shared/SuggestionPaper";
|
||||
import type { SuggestionModel } from "metabase-enterprise/documents/components/Editor/types";
|
||||
import { getCurrentDocument } from "metabase-enterprise/documents/selectors";
|
||||
import type { SearchResult } from "metabase-types/api";
|
||||
|
||||
import { EntitySearchSection } from "../shared/EntitySearchSection";
|
||||
import type { SuggestionModel } from "../shared/types";
|
||||
import { useEntitySuggestions } from "../shared/useEntitySuggestions";
|
||||
|
||||
import type { MentionCommandProps } from "./MentionExtension";
|
||||
@@ -23,6 +23,7 @@ interface MentionSuggestionProps {
|
||||
range: Range;
|
||||
query: string;
|
||||
searchModels?: SuggestionModel[];
|
||||
canBrowseAll?: boolean;
|
||||
}
|
||||
|
||||
interface SuggestionRef {
|
||||
@@ -33,7 +34,15 @@ const MentionSuggestionComponent = forwardRef<
|
||||
SuggestionRef,
|
||||
MentionSuggestionProps
|
||||
>(function MentionSuggestionComponent(
|
||||
{ items: _items, command, editor, range: _range, query, searchModels },
|
||||
{
|
||||
items: _items,
|
||||
command,
|
||||
editor,
|
||||
range,
|
||||
query,
|
||||
searchModels,
|
||||
canBrowseAll = true,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const document = useSelector(getCurrentDocument);
|
||||
@@ -49,7 +58,7 @@ const MentionSuggestionComponent = forwardRef<
|
||||
model: item.model,
|
||||
label: item.label,
|
||||
href: item.href,
|
||||
document,
|
||||
document, // only used for analytics tracking purposes
|
||||
});
|
||||
},
|
||||
[command, document],
|
||||
@@ -61,12 +70,15 @@ const MentionSuggestionComponent = forwardRef<
|
||||
searchResults,
|
||||
selectedIndex,
|
||||
modal,
|
||||
selectedSearchModelName,
|
||||
handlers,
|
||||
} = useEntitySuggestions({
|
||||
query,
|
||||
editor,
|
||||
range,
|
||||
searchModels,
|
||||
onSelectEntity,
|
||||
canBrowseAll,
|
||||
});
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
@@ -78,7 +90,10 @@ const MentionSuggestionComponent = forwardRef<
|
||||
}
|
||||
|
||||
return (
|
||||
<SuggestionPaper aria-label={t`Mention Dialog`}>
|
||||
<SuggestionPaper
|
||||
aria-label={t`Mention Dialog`}
|
||||
key={selectedSearchModelName}
|
||||
>
|
||||
<EntitySearchSection
|
||||
menuItems={menuItems}
|
||||
selectedIndex={selectedIndex}
|
||||
@@ -90,6 +105,8 @@ const MentionSuggestionComponent = forwardRef<
|
||||
onModalSelect={handlers.handleModalSelect}
|
||||
onModalClose={handlers.handleModalClose}
|
||||
onItemHover={handlers.hoverHandler}
|
||||
selectedSearchModelName={selectedSearchModelName}
|
||||
canBrowseAll={canBrowseAll}
|
||||
/>
|
||||
</SuggestionPaper>
|
||||
);
|
||||
@@ -99,15 +116,22 @@ export const MentionSuggestion = MentionSuggestionComponent;
|
||||
|
||||
export const createMentionSuggestion = ({
|
||||
searchModels,
|
||||
canBrowseAll,
|
||||
}: {
|
||||
searchModels: SuggestionModel[];
|
||||
canBrowseAll?: boolean;
|
||||
}) => {
|
||||
return forwardRef<
|
||||
SuggestionRef,
|
||||
Omit<MentionSuggestionProps, "searchModels">
|
||||
>(function MentionSuggestionWrapper(props, ref) {
|
||||
return (
|
||||
<MentionSuggestion {...props} ref={ref} searchModels={searchModels} />
|
||||
<MentionSuggestion
|
||||
{...props}
|
||||
ref={ref}
|
||||
searchModels={searchModels}
|
||||
canBrowseAll={canBrowseAll}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -16,6 +16,7 @@ interface MentionProps {
|
||||
type?: string;
|
||||
id?: number | string;
|
||||
model?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export const MetabotMentionPluginKey = new PluginKey("metabot-mention");
|
||||
@@ -53,8 +54,10 @@ export const MetabotMentionExtension = Extension.create<MentionOptions>({
|
||||
attrs: {
|
||||
entityId: props.id,
|
||||
model: props.model,
|
||||
label: props.label,
|
||||
},
|
||||
})
|
||||
.insertContent(" ") // Add space after mention
|
||||
.run();
|
||||
}
|
||||
},
|
||||
|
||||
@@ -27,8 +27,11 @@ import {
|
||||
useGetDocumentQuery,
|
||||
useListMentionsQuery,
|
||||
} from "metabase-enterprise/api";
|
||||
import type { SuggestionModel } from "metabase-enterprise/documents/components/Editor/types";
|
||||
import { updateMentionsCache } from "metabase-enterprise/documents/documents.slice";
|
||||
import {
|
||||
METABSE_PROTOCOL_MD_LINK,
|
||||
parseMetabaseProtocolMarkdownLink,
|
||||
} from "metabase-enterprise/metabot/utils/links";
|
||||
import type {
|
||||
Card,
|
||||
CardDisplayType,
|
||||
@@ -38,12 +41,14 @@ import type {
|
||||
Document,
|
||||
MentionableUser,
|
||||
Table,
|
||||
Transform,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import {
|
||||
entityToUrlableModel,
|
||||
isMentionableUser,
|
||||
} from "../shared/suggestionUtils";
|
||||
import type { SuggestionModel } from "../shared/types";
|
||||
|
||||
import styles from "./SmartLinkNode.module.css";
|
||||
|
||||
@@ -52,6 +57,7 @@ export type SmartLinkEntity =
|
||||
| Dashboard
|
||||
| Collection
|
||||
| Table
|
||||
| Transform
|
||||
| Database
|
||||
| Document
|
||||
| MentionableUser;
|
||||
@@ -222,6 +228,24 @@ export const SmartLink = Node.create<{
|
||||
};
|
||||
}
|
||||
|
||||
return null; // Return null to prevent node creation
|
||||
},
|
||||
}),
|
||||
nodePasteRule({
|
||||
find: new RegExp(METABSE_PROTOCOL_MD_LINK, "g"),
|
||||
type: this.type,
|
||||
getAttributes: (match) => {
|
||||
const url = match[0];
|
||||
const parsedEntity = parseMetabaseProtocolMarkdownLink(url);
|
||||
|
||||
if (parsedEntity) {
|
||||
return {
|
||||
entityId: parsedEntity.id,
|
||||
model: parsedEntity.model,
|
||||
label: parsedEntity.name,
|
||||
};
|
||||
}
|
||||
|
||||
return null; // Return null to prevent node creation
|
||||
},
|
||||
}),
|
||||
@@ -229,7 +253,7 @@ export const SmartLink = Node.create<{
|
||||
},
|
||||
});
|
||||
|
||||
const useEntityData = (
|
||||
export const useEntityData = (
|
||||
entityId: number | null,
|
||||
model: SuggestionModel | null,
|
||||
) => {
|
||||
@@ -335,17 +359,27 @@ const useEntityData = (
|
||||
|
||||
export const SmartLinkComponent = memo(
|
||||
({ node }: NodeViewProps) => {
|
||||
const { entityId, model } = node.attrs;
|
||||
const { entity, isLoading, error } = useEntityData(entityId, model);
|
||||
const { entityId, model, label } = node.attrs;
|
||||
|
||||
const {
|
||||
entity: networkEntity,
|
||||
isLoading,
|
||||
error,
|
||||
} = useEntityData(entityId, model);
|
||||
const cachedEntity = { id: parseInt(entityId, 10), model, name: label };
|
||||
const entity = networkEntity || cachedEntity;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
useEffect(() => {
|
||||
if (entity?.name) {
|
||||
dispatch(updateMentionsCache({ entityId, model, name: entity.name }));
|
||||
if (entity) {
|
||||
const name =
|
||||
"display_name" in entity ? entity.display_name : entity?.name;
|
||||
dispatch(updateMentionsCache({ entityId, model, name }));
|
||||
}
|
||||
}, [dispatch, entity?.name, entityId, model]);
|
||||
}, [dispatch, entity, entityId, model]);
|
||||
|
||||
if (isLoading) {
|
||||
const showLoading = isLoading && !entity;
|
||||
if (showLoading) {
|
||||
return (
|
||||
<NodeViewWrapper as="span">
|
||||
<span className={styles.smartLink}>
|
||||
@@ -382,7 +416,15 @@ export const SmartLinkComponent = memo(
|
||||
const entityUrlableModel = entityToUrlableModel(entity, model);
|
||||
const entityUrl = modelToUrl(entityUrlableModel);
|
||||
|
||||
const iconData = getIcon(entityToObjectWithModel(entity, model));
|
||||
const iconData =
|
||||
entity === cachedEntity
|
||||
? getIcon(cachedEntity)
|
||||
: getIcon(
|
||||
entityToObjectWithModel(
|
||||
entity as NonNullable<typeof networkEntity>,
|
||||
model,
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<NodeViewWrapper as="span">
|
||||
@@ -428,7 +470,7 @@ function entityToObjectWithModel(
|
||||
};
|
||||
}
|
||||
|
||||
function getName(entity: SmartLinkEntity) {
|
||||
function getName(entity: { name?: string; display_name?: string }) {
|
||||
if ("display_name" in entity && entity.display_name !== "") {
|
||||
return entity.display_name;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
setupTableEndpoints,
|
||||
} from "__support__/server-mocks";
|
||||
import { renderWithProviders, screen, waitFor } from "__support__/ui";
|
||||
import type { SuggestionModel } from "metabase-enterprise/documents/components/Editor/types";
|
||||
import {
|
||||
createMockCard,
|
||||
createMockCollection,
|
||||
@@ -19,6 +18,8 @@ import {
|
||||
createMockTable,
|
||||
} from "metabase-types/api/mocks";
|
||||
|
||||
import type { SuggestionModel } from "../shared/types";
|
||||
|
||||
import { SmartLinkComponent, type SmartLinkEntity } from "./SmartLinkNode";
|
||||
|
||||
function createProps(model: SuggestionModel, entity: SmartLinkEntity) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import type { SearchResult } from "metabase-types/api";
|
||||
|
||||
interface EntitySearchSectionProps {
|
||||
canBrowseAll: boolean;
|
||||
menuItems: MenuItem[];
|
||||
selectedIndex: number;
|
||||
onItemSelect: (index: number) => void;
|
||||
@@ -21,6 +22,7 @@ interface EntitySearchSectionProps {
|
||||
onModalSelect: (item: QuestionPickerValueItem) => void;
|
||||
onModalClose: () => void;
|
||||
onItemHover: (index: number) => void;
|
||||
selectedSearchModelName?: string;
|
||||
}
|
||||
|
||||
export function EntitySearchSection({
|
||||
@@ -34,9 +36,18 @@ export function EntitySearchSection({
|
||||
onModalSelect,
|
||||
onModalClose,
|
||||
onItemHover,
|
||||
selectedSearchModelName,
|
||||
canBrowseAll,
|
||||
}: EntitySearchSectionProps) {
|
||||
return (
|
||||
<>
|
||||
{selectedSearchModelName && (
|
||||
<Box py="xs">
|
||||
<Text size="sm" c="text-light" fw="bold">
|
||||
{selectedSearchModelName}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{menuItems.map((item, index) => (
|
||||
<MenuItemComponent
|
||||
key={index}
|
||||
@@ -46,20 +57,31 @@ export function EntitySearchSection({
|
||||
onMouseEnter={() => onItemHover(index)}
|
||||
/>
|
||||
))}
|
||||
{query.length > 0 && searchResults.length === 0 ? (
|
||||
{query.length > 0 &&
|
||||
menuItems.length === 0 &&
|
||||
searchResults.length === 0 ? (
|
||||
<Box p="sm" ta="center">
|
||||
<Text size="md" c="text-medium">{t`No results found`}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
{menuItems.length > 0 && <Divider my="sm" mx="sm" />}
|
||||
<SearchResultsFooter
|
||||
isSelected={selectedIndex === menuItems.length}
|
||||
onClick={onFooterClick}
|
||||
onMouseEnter={() => onItemHover(menuItems.length)}
|
||||
/>
|
||||
|
||||
{modal === "question-picker" && (
|
||||
<QuestionPickerModal onChange={onModalSelect} onClose={onModalClose} />
|
||||
{canBrowseAll && (
|
||||
<>
|
||||
{menuItems.length > 0 && <Divider my="sm" mx="sm" />}
|
||||
|
||||
<SearchResultsFooter
|
||||
isSelected={selectedIndex === menuItems.length}
|
||||
onClick={onFooterClick}
|
||||
onMouseEnter={() => onItemHover(menuItems.length)}
|
||||
/>
|
||||
|
||||
{modal === "question-picker" && (
|
||||
<QuestionPickerModal
|
||||
onChange={onModalSelect}
|
||||
onClose={onModalClose}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { SuggestionModel } from "metabase-enterprise/documents/components/Editor/types";
|
||||
import type { SuggestionModel } from "./types";
|
||||
|
||||
export const LINK_SEARCH_MODELS: SuggestionModel[] = [
|
||||
"card",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { getTranslatedEntityName } from "metabase/common/utils/model-names";
|
||||
import { getIcon } from "metabase/lib/icon";
|
||||
import { getName } from "metabase/lib/name";
|
||||
import type { UrlableModel } from "metabase/lib/urls/modelToUrl";
|
||||
import type { MenuItem } from "metabase-enterprise/documents/components/Editor/shared/MenuComponents";
|
||||
import type { SuggestionModel } from "metabase-enterprise/documents/components/Editor/types";
|
||||
import type {
|
||||
Database,
|
||||
MentionableUser,
|
||||
@@ -11,6 +11,8 @@ import type {
|
||||
} from "metabase-types/api";
|
||||
import { isObject } from "metabase-types/guards";
|
||||
|
||||
import type { SuggestionModel } from "./types";
|
||||
|
||||
export const filterRecents = (item: RecentItem, models: SuggestionModel[]) =>
|
||||
models.includes(item.model);
|
||||
|
||||
@@ -80,6 +82,21 @@ export function buildUserMenuItems(
|
||||
});
|
||||
}
|
||||
|
||||
export function buildSearchModelMenuItems(
|
||||
searchModels: SuggestionModel[],
|
||||
onSelect: (model: SuggestionModel) => void,
|
||||
): MenuItem[] {
|
||||
return searchModels.map((model) => {
|
||||
return {
|
||||
icon: getIcon({ model }).name,
|
||||
label: getTranslatedEntityName(model) || model,
|
||||
model,
|
||||
action: () => onSelect(model),
|
||||
hasSubmenu: true,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function entityToUrlableModel<
|
||||
T extends {
|
||||
id: string | number;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import type { SearchModel } from "metabase-types/api";
|
||||
|
||||
export type SuggestionModel = SearchModel | "transform" | "user";
|
||||
@@ -3,7 +3,6 @@ import { useMemo } from "react";
|
||||
import { useListRecentsQuery, useSearchQuery } from "metabase/api";
|
||||
import { useListMentionsQuery } from "metabase-enterprise/api";
|
||||
import type { MenuItem } from "metabase-enterprise/documents/components/Editor/shared/MenuComponents";
|
||||
import type { SuggestionModel } from "metabase-enterprise/documents/components/Editor/types";
|
||||
import type {
|
||||
MentionableUser,
|
||||
RecentItem,
|
||||
@@ -11,6 +10,8 @@ import type {
|
||||
SearchResult,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import type { SuggestionModel } from "../shared/types";
|
||||
|
||||
import {
|
||||
LINK_SEARCH_LIMIT,
|
||||
LINK_SEARCH_MODELS,
|
||||
@@ -29,6 +30,7 @@ interface UseEntitySearchOptions {
|
||||
onSelectSearchResult: (item: SearchResult) => void;
|
||||
onSelectUser: (item: MentionableUser) => void;
|
||||
enabled?: boolean;
|
||||
shouldFetchRecents?: boolean;
|
||||
searchModels?: SuggestionModel[];
|
||||
}
|
||||
|
||||
@@ -44,10 +46,9 @@ export function useEntitySearch({
|
||||
onSelectSearchResult,
|
||||
onSelectUser,
|
||||
enabled = true,
|
||||
shouldFetchRecents = true,
|
||||
searchModels = LINK_SEARCH_MODELS,
|
||||
}: UseEntitySearchOptions): UseEntitySearchResult {
|
||||
const shouldFetchRecents = enabled && query.length === 0;
|
||||
|
||||
const { data: recents = [], isLoading: isRecentsLoading } =
|
||||
useListRecentsQuery(undefined, {
|
||||
refetchOnMountOrArgChange: 10, // only refetch if the cache is more than 10 seconds stale
|
||||
@@ -73,7 +74,7 @@ export function useEntitySearch({
|
||||
limit: LINK_SEARCH_LIMIT,
|
||||
},
|
||||
{
|
||||
skip: !enabled || !query || query.length === 0,
|
||||
skip: !enabled || shouldFetchRecents,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -98,7 +99,7 @@ export function useEntitySearch({
|
||||
const menuItems = useMemo(() => {
|
||||
const items: MenuItem[] = [];
|
||||
|
||||
if (query.length > 0) {
|
||||
if (!shouldFetchRecents) {
|
||||
if (!isUsersLoading) {
|
||||
items.push(...buildUserMenuItems(users, onSelectUser));
|
||||
}
|
||||
@@ -116,7 +117,7 @@ export function useEntitySearch({
|
||||
|
||||
return items;
|
||||
}, [
|
||||
query,
|
||||
shouldFetchRecents,
|
||||
searchResults,
|
||||
users,
|
||||
isSearchLoading,
|
||||
@@ -128,13 +129,9 @@ export function useEntitySearch({
|
||||
onSelectUser,
|
||||
]);
|
||||
|
||||
const isLoading =
|
||||
(shouldFetchRecents && isRecentsLoading) ||
|
||||
(query.length > 0 && isSearchLoading);
|
||||
|
||||
return {
|
||||
menuItems,
|
||||
isLoading,
|
||||
isLoading: shouldFetchRecents ? isRecentsLoading : isSearchLoading,
|
||||
searchResults,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,21 +1,26 @@
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { Editor, Range } from "@tiptap/core";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
|
||||
import type { QuestionPickerValueItem } from "metabase/common/components/Pickers/QuestionPicker/types";
|
||||
import type { QuestionPickerValueItem } from "metabase/common/components/Pickers/QuestionPicker";
|
||||
import { getTranslatedEntityName } from "metabase/common/utils/model-names";
|
||||
import { modelToUrl } from "metabase/lib/urls/modelToUrl";
|
||||
import type { SuggestionModel } from "metabase-enterprise/documents/components/Editor/types";
|
||||
import type {
|
||||
MentionableUser,
|
||||
RecentItem,
|
||||
SearchResult,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import { entityToUrlableModel } from "./suggestionUtils";
|
||||
import {
|
||||
buildSearchModelMenuItems,
|
||||
entityToUrlableModel,
|
||||
} from "./suggestionUtils";
|
||||
import type { SuggestionModel } from "./types";
|
||||
import { useEntitySearch } from "./useEntitySearch";
|
||||
|
||||
interface UseEntitySuggestionsOptions {
|
||||
query: string;
|
||||
editor: Editor;
|
||||
range?: Range;
|
||||
onSelectEntity: (item: {
|
||||
id: number | string;
|
||||
model: string;
|
||||
@@ -24,6 +29,7 @@ interface UseEntitySuggestionsOptions {
|
||||
}) => void;
|
||||
enabled?: boolean;
|
||||
searchModels?: SuggestionModel[];
|
||||
canBrowseAll: boolean;
|
||||
}
|
||||
|
||||
interface UseEntitySuggestionsResult {
|
||||
@@ -33,6 +39,7 @@ interface UseEntitySuggestionsResult {
|
||||
selectedIndex: number;
|
||||
modal: "question-picker" | null;
|
||||
totalItems: number;
|
||||
selectedSearchModelName?: string;
|
||||
handlers: {
|
||||
selectItem: (index: number) => void;
|
||||
upHandler: () => void;
|
||||
@@ -49,12 +56,16 @@ interface UseEntitySuggestionsResult {
|
||||
export function useEntitySuggestions({
|
||||
query,
|
||||
editor,
|
||||
range,
|
||||
onSelectEntity,
|
||||
enabled = true,
|
||||
searchModels,
|
||||
canBrowseAll,
|
||||
}: UseEntitySuggestionsOptions): UseEntitySuggestionsResult {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [modal, setModal] = useState<"question-picker" | null>(null);
|
||||
const [selectedSearchModel, setSelectedSearchModel] =
|
||||
useState<SuggestionModel | null>(null);
|
||||
|
||||
const handleRecentSelect = useCallback(
|
||||
(item: RecentItem) => {
|
||||
@@ -92,16 +103,76 @@ export function useEntitySuggestions({
|
||||
[onSelectEntity],
|
||||
);
|
||||
|
||||
const { menuItems, isLoading, searchResults } = useEntitySearch({
|
||||
const handleSearchModelSelect = useCallback(
|
||||
(model: SuggestionModel) => {
|
||||
// clear any input the user made to filter search models
|
||||
if (range) {
|
||||
editor.chain().focus().deleteRange(range).insertContent("@").run();
|
||||
}
|
||||
setSelectedSearchModel(model);
|
||||
setSelectedIndex(0);
|
||||
},
|
||||
[editor, range],
|
||||
);
|
||||
|
||||
const filteredSearchModels = useMemo(() => {
|
||||
if (!searchModels) {
|
||||
return undefined;
|
||||
}
|
||||
return searchModels.filter((model) => {
|
||||
const modelName = getTranslatedEntityName(model) ?? "";
|
||||
return modelName.toLowerCase().startsWith(query.toLowerCase());
|
||||
});
|
||||
}, [searchModels, query]);
|
||||
|
||||
// Use selected model if available, otherwise use all models for fallback search
|
||||
const effectiveSearchModels = useMemo(() => {
|
||||
return selectedSearchModel ? [selectedSearchModel] : filteredSearchModels;
|
||||
}, [selectedSearchModel, filteredSearchModels]);
|
||||
|
||||
// Determine if we're in model selection mode
|
||||
const searchModelMenuItems = useMemo(() => {
|
||||
return selectedSearchModel || filteredSearchModels === undefined
|
||||
? []
|
||||
: buildSearchModelMenuItems(
|
||||
filteredSearchModels,
|
||||
handleSearchModelSelect,
|
||||
);
|
||||
}, [filteredSearchModels, selectedSearchModel, handleSearchModelSelect]);
|
||||
|
||||
// Check if we have any matching search models
|
||||
const hasSearchModels = (searchModels?.length ?? 0) > 0;
|
||||
const hasMatchingSearchModels = (effectiveSearchModels?.length ?? 0) > 0;
|
||||
const isInModelSelectionMode =
|
||||
!selectedSearchModel && hasSearchModels && hasMatchingSearchModels;
|
||||
|
||||
const {
|
||||
menuItems: entityMenuItems,
|
||||
isLoading,
|
||||
searchResults,
|
||||
} = useEntitySearch({
|
||||
query,
|
||||
onSelectRecent: handleRecentSelect,
|
||||
onSelectSearchResult: handleSearchResultSelect,
|
||||
onSelectUser: handleUserSelect,
|
||||
enabled,
|
||||
searchModels,
|
||||
enabled: enabled && !isInModelSelectionMode,
|
||||
shouldFetchRecents:
|
||||
enabled &&
|
||||
query.length === 0 &&
|
||||
!isInModelSelectionMode &&
|
||||
!selectedSearchModel,
|
||||
searchModels: effectiveSearchModels,
|
||||
});
|
||||
|
||||
const totalItems = menuItems.length + 1;
|
||||
// Combine menu items based on current mode
|
||||
const menuItems = useMemo(() => {
|
||||
if (isInModelSelectionMode) {
|
||||
return searchModelMenuItems;
|
||||
}
|
||||
return entityMenuItems;
|
||||
}, [isInModelSelectionMode, searchModelMenuItems, entityMenuItems]);
|
||||
|
||||
const totalItems = menuItems.length + Number(canBrowseAll);
|
||||
|
||||
const selectItem = useCallback(
|
||||
(index: number) => {
|
||||
@@ -182,13 +253,19 @@ export function useEntitySuggestions({
|
||||
setSelectedIndex(0);
|
||||
}, [menuItems.length]);
|
||||
|
||||
// Get the translated name for the selected search model
|
||||
const selectedSearchModelName = selectedSearchModel
|
||||
? getTranslatedEntityName(selectedSearchModel) || selectedSearchModel
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
menuItems,
|
||||
isLoading,
|
||||
isLoading: isInModelSelectionMode ? false : isLoading,
|
||||
searchResults,
|
||||
selectedIndex,
|
||||
modal,
|
||||
totalItems,
|
||||
selectedSearchModelName,
|
||||
handlers: {
|
||||
selectItem,
|
||||
upHandler,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
type Placement,
|
||||
type VirtualElement,
|
||||
autoUpdate,
|
||||
computePosition,
|
||||
@@ -11,11 +12,33 @@ import type {
|
||||
SuggestionKeyDownProps,
|
||||
SuggestionProps,
|
||||
} from "@tiptap/suggestion";
|
||||
import { merge } from "icepick";
|
||||
import type React from "react";
|
||||
|
||||
type DeepPartial<T> = {
|
||||
[K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
|
||||
};
|
||||
|
||||
interface SuggestionRendererOptions {
|
||||
positionOptions: {
|
||||
placement: Placement;
|
||||
fallbackPlacements: Placement[];
|
||||
};
|
||||
}
|
||||
|
||||
const defaultRendererOptions: SuggestionRendererOptions = {
|
||||
positionOptions: {
|
||||
placement: "bottom-start",
|
||||
fallbackPlacements: ["top-start", "bottom-end", "top-end"],
|
||||
},
|
||||
};
|
||||
|
||||
export const createSuggestionRenderer = <I = unknown, TSelected = unknown>(
|
||||
SuggestionComponent: React.ComponentType<SuggestionProps<I, TSelected>>,
|
||||
options?: DeepPartial<SuggestionRendererOptions>,
|
||||
) => {
|
||||
const config = merge(defaultRendererOptions, options);
|
||||
|
||||
return () => {
|
||||
let component: ReactRenderer | undefined;
|
||||
let currentClientRect: (() => DOMRect | null) | null | undefined;
|
||||
@@ -42,11 +65,11 @@ export const createSuggestionRenderer = <I = unknown, TSelected = unknown>(
|
||||
};
|
||||
|
||||
computePosition(virtualElement, element, {
|
||||
placement: "bottom-start",
|
||||
placement: config.positionOptions.placement,
|
||||
strategy: "fixed",
|
||||
middleware: [
|
||||
flip({
|
||||
fallbackPlacements: ["top-start", "bottom-end", "top-end"],
|
||||
fallbackPlacements: config.positionOptions.fallbackPlacements,
|
||||
padding: 4,
|
||||
}),
|
||||
shift({
|
||||
|
||||
@@ -7,6 +7,7 @@ import { completion } from "./utils";
|
||||
|
||||
export function PythonEditor({
|
||||
value,
|
||||
proposedValue,
|
||||
onChange,
|
||||
withPandasCompletions,
|
||||
className,
|
||||
@@ -14,6 +15,8 @@ export function PythonEditor({
|
||||
...rest
|
||||
}: {
|
||||
value: string;
|
||||
proposedValue?: string;
|
||||
|
||||
onChange?: (value: string) => void;
|
||||
withPandasCompletions?: boolean;
|
||||
className?: string;
|
||||
@@ -23,6 +26,7 @@ export function PythonEditor({
|
||||
<CodeEditor
|
||||
className={cx(S.editor, className)}
|
||||
value={value}
|
||||
proposedValue={proposedValue}
|
||||
onChange={onChange}
|
||||
language="python"
|
||||
extensions={withPandasCompletions ? completion : undefined}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { t } from "ttag";
|
||||
|
||||
import Link from "metabase/common/components/Link";
|
||||
import RunButtonWithTooltip from "metabase/query_builder/components/RunButtonWithTooltip";
|
||||
import { Box, Checkbox, Flex, Icon, Stack } from "metabase/ui";
|
||||
import { Box, Button, Checkbox, Flex, Icon, Stack, Tooltip } from "metabase/ui";
|
||||
import { SHARED_LIB_IMPORT_PATH } from "metabase-enterprise/transforms-python/constants";
|
||||
import { getPythonLibraryUrl } from "metabase-enterprise/transforms-python/urls";
|
||||
|
||||
@@ -16,6 +16,7 @@ import { hasImport, insertImport, removeImport } from "./utils";
|
||||
|
||||
type PythonEditorBodyProps = {
|
||||
source: string;
|
||||
proposedSource?: string;
|
||||
isRunnable: boolean;
|
||||
onChange: (source: string) => void;
|
||||
onRun?: () => void;
|
||||
@@ -24,12 +25,15 @@ type PythonEditorBodyProps = {
|
||||
isDirty?: boolean;
|
||||
tables?: Record<string, number>;
|
||||
withDebugger?: boolean;
|
||||
onAcceptProposed?: () => void;
|
||||
onRejectProposed?: () => void;
|
||||
};
|
||||
|
||||
const EDITOR_HEIGHT = 400;
|
||||
|
||||
export function PythonEditorBody({
|
||||
source,
|
||||
proposedSource,
|
||||
onChange,
|
||||
isRunnable,
|
||||
onRun,
|
||||
@@ -37,29 +41,60 @@ export function PythonEditorBody({
|
||||
isRunning,
|
||||
isDirty,
|
||||
withDebugger,
|
||||
onAcceptProposed,
|
||||
onRejectProposed,
|
||||
}: PythonEditorBodyProps) {
|
||||
return (
|
||||
<MaybeResizableBox resizable={withDebugger}>
|
||||
<Flex h="100%" align="end" bg="bg-light" pos="relative">
|
||||
<PythonEditor
|
||||
value={source}
|
||||
proposedValue={proposedSource}
|
||||
onChange={onChange}
|
||||
withPandasCompletions
|
||||
data-testid="python-editor"
|
||||
/>
|
||||
|
||||
{withDebugger && (
|
||||
<Box p="md">
|
||||
<RunButtonWithTooltip
|
||||
disabled={!isRunnable}
|
||||
isRunning={isRunning}
|
||||
isDirty={isDirty}
|
||||
onRun={onRun}
|
||||
onCancel={onCancel}
|
||||
getTooltip={() => t`Run Python script`}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
<Stack m="1rem" gap="md" mt="auto">
|
||||
{proposedSource && onRejectProposed && onAcceptProposed && (
|
||||
<>
|
||||
<Tooltip label={t`Accept proposed changes`} position="left">
|
||||
<Button
|
||||
variant="filled"
|
||||
bg="success"
|
||||
px="0"
|
||||
w="2.5rem"
|
||||
onClick={onAcceptProposed}
|
||||
>
|
||||
<Icon name="check" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip label={t`Reject proposed changes`} position="left">
|
||||
<Button
|
||||
w="2.5rem"
|
||||
px="0"
|
||||
variant="filled"
|
||||
bg="danger"
|
||||
onClick={onRejectProposed}
|
||||
>
|
||||
<Icon name="close" />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
{withDebugger && (
|
||||
<Box p="md">
|
||||
<RunButtonWithTooltip
|
||||
disabled={!isRunnable}
|
||||
isRunning={isRunning}
|
||||
isDirty={isDirty}
|
||||
onRun={onRun}
|
||||
onCancel={onCancel}
|
||||
getTooltip={() => t`Run Python script`}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
<SharedLibraryActions source={source} onChange={onChange} />
|
||||
</Flex>
|
||||
</MaybeResizableBox>
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
PythonTransformSource,
|
||||
PythonTransformTableAliases,
|
||||
Table,
|
||||
Transform,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import { PythonDataPicker } from "./PythonDataPicker";
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
useShouldShowPythonDebugger,
|
||||
useTestPythonTransform,
|
||||
} from "./utils";
|
||||
import { useRegisterMetabotTransformContext } from "metabase-enterprise/transforms/hooks/use-register-transform-metabot-context";
|
||||
|
||||
export type PythonTransformSourceDraft = {
|
||||
type: "python";
|
||||
@@ -29,25 +31,37 @@ export type PythonTransformSourceDraft = {
|
||||
};
|
||||
|
||||
export type PythonTransformEditorProps = {
|
||||
transform?: Transform | undefined;
|
||||
initialSource: PythonTransformSourceDraft;
|
||||
proposedSource?: PythonTransformSource;
|
||||
isNew?: boolean;
|
||||
isSaving?: boolean;
|
||||
isRunnable?: boolean;
|
||||
onChange?: (newSource: PythonTransformSourceDraft) => void;
|
||||
onSave: (newSource: PythonTransformSource) => void;
|
||||
onCancel: () => void;
|
||||
onRejectProposed?: () => void;
|
||||
onAcceptProposed?: (query: PythonTransformSource) => void;
|
||||
};
|
||||
|
||||
export function PythonTransformEditor({
|
||||
transform,
|
||||
initialSource,
|
||||
proposedSource,
|
||||
isNew = true,
|
||||
isSaving = false,
|
||||
isRunnable = true,
|
||||
onChange,
|
||||
onSave,
|
||||
onCancel,
|
||||
onRejectProposed,
|
||||
onAcceptProposed,
|
||||
}: PythonTransformEditorProps) {
|
||||
const [source, setSource] = useState(initialSource);
|
||||
const [isSourceDirty, setIsSourceDirty] = useState(false);
|
||||
|
||||
useRegisterMetabotTransformContext(transform, proposedSource ?? source);
|
||||
|
||||
const { isRunning, isDirty, cancel, run, executionResult } =
|
||||
useTestPythonTransform(source);
|
||||
|
||||
@@ -58,6 +72,7 @@ export function PythonTransformEditor({
|
||||
};
|
||||
setSource(newSource);
|
||||
setIsSourceDirty(true);
|
||||
onChange?.(newSource);
|
||||
};
|
||||
|
||||
const handleDataChange = (
|
||||
@@ -87,6 +102,14 @@ export function PythonTransformEditor({
|
||||
}
|
||||
};
|
||||
|
||||
const handleAcceptProposed =
|
||||
proposedSource && onAcceptProposed
|
||||
? () => {
|
||||
handleScriptChange(proposedSource.body);
|
||||
onAcceptProposed(proposedSource);
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const showDebugger = useShouldShowPythonDebugger();
|
||||
|
||||
const handleCmdEnter = () => {
|
||||
@@ -134,8 +157,11 @@ export function PythonTransformEditor({
|
||||
onRun={run}
|
||||
onCancel={cancel}
|
||||
source={source.body}
|
||||
proposedSource={proposedSource?.body}
|
||||
onChange={handleScriptChange}
|
||||
withDebugger={showDebugger}
|
||||
onAcceptProposed={handleAcceptProposed}
|
||||
onRejectProposed={onRejectProposed}
|
||||
/>
|
||||
{showDebugger && (
|
||||
<PythonEditorResults
|
||||
|
||||
@@ -16,6 +16,7 @@ import type Database from "metabase-lib/v1/metadata/Database";
|
||||
import type NativeQuery from "metabase-lib/v1/queries/NativeQuery";
|
||||
import type {
|
||||
Database as ApiDatabase,
|
||||
DatasetQuery,
|
||||
NativeQuerySnippet,
|
||||
RecentItem,
|
||||
} from "metabase-types/api";
|
||||
@@ -56,6 +57,9 @@ type EditorBodyProps = {
|
||||
nativeEditorSelectedText?: string | null;
|
||||
|
||||
databases: ApiDatabase[];
|
||||
proposedQuestion?: Question;
|
||||
onRejectProposed?: () => void;
|
||||
onAcceptProposed?: (query: DatasetQuery) => void;
|
||||
};
|
||||
|
||||
export function EditorBody({
|
||||
@@ -77,6 +81,9 @@ export function EditorBody({
|
||||
onChangeNativeEditorSelection,
|
||||
nativeEditorSelectedText,
|
||||
onOpenModal,
|
||||
proposedQuestion,
|
||||
onRejectProposed,
|
||||
onAcceptProposed,
|
||||
}: EditorBodyProps) {
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const reportTimezone = useSetting("report-timezone-long");
|
||||
@@ -140,6 +147,9 @@ export function EditorBody({
|
||||
setNativeEditorSelectedRange={onChangeNativeEditorSelection}
|
||||
nativeEditorSelectedText={nativeEditorSelectedText}
|
||||
onOpenModal={onOpenModal}
|
||||
proposedQuestion={proposedQuestion}
|
||||
onRejectProposed={onRejectProposed}
|
||||
onAcceptProposed={onAcceptProposed}
|
||||
/>
|
||||
) : (
|
||||
<ResizableBox
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { QueryValidationResult } from "../types";
|
||||
|
||||
type EditorHeaderProps = {
|
||||
validationResult: QueryValidationResult;
|
||||
name?: string;
|
||||
isNew: boolean;
|
||||
isQueryDirty: boolean;
|
||||
isSaving: boolean;
|
||||
@@ -17,6 +18,7 @@ type EditorHeaderProps = {
|
||||
|
||||
export function EditorHeader({
|
||||
validationResult,
|
||||
name,
|
||||
isNew,
|
||||
isQueryDirty,
|
||||
isSaving,
|
||||
@@ -28,7 +30,7 @@ export function EditorHeader({
|
||||
|
||||
return (
|
||||
<EditBar
|
||||
title={getTitle(isNew)}
|
||||
title={getTitle(isNew, name)}
|
||||
admin
|
||||
buttons={[
|
||||
<Button key="cancel" small onClick={onCancel}>{t`Cancel`}</Button>,
|
||||
@@ -46,11 +48,11 @@ export function EditorHeader({
|
||||
);
|
||||
}
|
||||
|
||||
function getTitle(isNew: boolean) {
|
||||
function getTitle(isNew: boolean, name?: string) {
|
||||
if (isNew) {
|
||||
return t`You’re creating a new transform`;
|
||||
} else {
|
||||
return t`You’re editing a transform`;
|
||||
return t`You’re editing the "${name}" transform`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useDisclosure, useHotkeys, useToggle } from "@mantine/hooks";
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useListDatabasesQuery } from "metabase/api";
|
||||
@@ -12,6 +12,7 @@ import type Question from "metabase-lib/v1/Question";
|
||||
import type {
|
||||
NativeQuerySnippet,
|
||||
QueryTransformSource,
|
||||
Transform,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import { useQueryMetadata } from "../../hooks/use-query-metadata";
|
||||
@@ -33,25 +34,35 @@ import {
|
||||
useInsertSnippetHandler,
|
||||
useSelectedText,
|
||||
} from "./utils";
|
||||
import { useRegisterMetabotTransformContext } from "metabase-enterprise/transforms/hooks/use-register-transform-metabot-context";
|
||||
|
||||
type QueryEditorProps = {
|
||||
transform?: Transform | undefined;
|
||||
initialSource: QueryTransformSource;
|
||||
proposedSource?: QueryTransformSource;
|
||||
isNew?: boolean;
|
||||
isSaving?: boolean;
|
||||
onSave: (source: QueryTransformSource) => void;
|
||||
onChange?: (source: QueryTransformSource) => void;
|
||||
onCancel: () => void;
|
||||
onRejectProposed?: () => void;
|
||||
onAcceptProposed?: (query: QueryTransformSource) => void;
|
||||
};
|
||||
|
||||
export function QueryEditor({
|
||||
transform,
|
||||
initialSource,
|
||||
proposedSource,
|
||||
isNew = true,
|
||||
isSaving = false,
|
||||
onSave,
|
||||
onChange,
|
||||
onCancel,
|
||||
onRejectProposed,
|
||||
onAcceptProposed,
|
||||
}: QueryEditorProps) {
|
||||
const { question, isQueryDirty, setQuestion } = useQueryState(
|
||||
initialSource.query,
|
||||
);
|
||||
const { question, proposedQuestion, isQueryDirty, setQuestion } =
|
||||
useQueryState(initialSource.query, proposedSource?.query);
|
||||
const { isInitiallyLoaded } = useQueryMetadata(question);
|
||||
const {
|
||||
result,
|
||||
@@ -61,14 +72,21 @@ export function QueryEditor({
|
||||
isResultDirty,
|
||||
runQuery,
|
||||
cancelQuery,
|
||||
} = useQueryResults(question);
|
||||
} = useQueryResults(question, proposedQuestion);
|
||||
const { isNative } = Lib.queryDisplayInfo(question.query());
|
||||
const [isShowingNativeQueryPreview, toggleNativeQueryPreview] = useToggle();
|
||||
const [isPreviewQueryModalOpen, togglePreviewQueryModal] = useToggle();
|
||||
const validationResult = getValidationResult(question.query());
|
||||
|
||||
const source = useMemo(() => {
|
||||
const query = proposedSource?.query ?? question.datasetQuery();
|
||||
return { type: "query" as const, query };
|
||||
}, [proposedSource, question]);
|
||||
useRegisterMetabotTransformContext(transform, source);
|
||||
|
||||
const handleChange = async (newQuestion: Question) => {
|
||||
setQuestion(newQuestion);
|
||||
onChange?.({ type: "query", query: question.datasetQuery() });
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
@@ -147,6 +165,7 @@ export function QueryEditor({
|
||||
>
|
||||
<EditorHeader
|
||||
validationResult={validationResult}
|
||||
name={transform?.name}
|
||||
isNew={isNew}
|
||||
isSaving={isSaving}
|
||||
isQueryDirty={isQueryDirty}
|
||||
@@ -157,6 +176,7 @@ export function QueryEditor({
|
||||
<Stack flex="2 1 100%" pos="relative">
|
||||
<EditorBody
|
||||
question={question}
|
||||
proposedQuestion={proposedQuestion}
|
||||
isNative={isNative}
|
||||
isRunnable={isRunnable}
|
||||
isRunning={isRunning}
|
||||
@@ -166,6 +186,12 @@ export function QueryEditor({
|
||||
onChange={handleChange}
|
||||
onRunQuery={runQuery}
|
||||
onCancelQuery={cancelQuery}
|
||||
onRejectProposed={onRejectProposed}
|
||||
onAcceptProposed={
|
||||
proposedSource
|
||||
? () => onAcceptProposed?.(proposedSource)
|
||||
: undefined
|
||||
}
|
||||
databases={databases?.data ?? []}
|
||||
onToggleDataReference={handleToggleDataReference}
|
||||
onToggleSnippetSidebar={handleToggleSnippetSidebar}
|
||||
@@ -186,7 +212,6 @@ export function QueryEditor({
|
||||
onRunQuery={runQuery}
|
||||
onCancelQuery={() => undefined}
|
||||
/>
|
||||
|
||||
{!isNative && (
|
||||
<NativeQuerySidebarToggle
|
||||
isShowingNativeQueryPreview={isShowingNativeQueryPreview}
|
||||
|
||||
@@ -8,13 +8,18 @@ import * as Lib from "metabase-lib";
|
||||
import Question from "metabase-lib/v1/Question";
|
||||
import type { DatasetQuery } from "metabase-types/api";
|
||||
|
||||
export function useQueryResults(question: Question) {
|
||||
export function useQueryResults(
|
||||
question: Question,
|
||||
proposedQuestion?: Question,
|
||||
) {
|
||||
const metadata = useSelector(getMetadata);
|
||||
const [lastRunQuery, setLastRunQuery] = useState<DatasetQuery | null>(null);
|
||||
|
||||
const currentQuestion = proposedQuestion ?? question;
|
||||
|
||||
const [{ value: results = null, loading: isRunning }, runQuery] = useAsyncFn(
|
||||
() => runQuestionQuery(question),
|
||||
[question],
|
||||
() => runQuestionQuery(currentQuestion),
|
||||
[currentQuestion],
|
||||
);
|
||||
|
||||
const { result, rawSeries, isRunnable, isResultDirty } = useMemo(() => {
|
||||
@@ -22,7 +27,7 @@ export function useQueryResults(question: Question) {
|
||||
? Question.create({
|
||||
dataset_query: lastRunQuery,
|
||||
metadata,
|
||||
visualization_settings: question.settings(),
|
||||
visualization_settings: currentQuestion.settings(),
|
||||
})
|
||||
: null;
|
||||
const result = results ? results[0] : null;
|
||||
@@ -30,9 +35,13 @@ export function useQueryResults(question: Question) {
|
||||
lastRunQuestion && result
|
||||
? [{ card: lastRunQuestion.card(), data: result.data }]
|
||||
: null;
|
||||
const isRunnable = Lib.canRun(question.query(), question.type());
|
||||
const isRunnable = Lib.canRun(
|
||||
currentQuestion.query(),
|
||||
currentQuestion.type(),
|
||||
);
|
||||
const isResultDirty =
|
||||
lastRunQuestion == null || question.isDirtyComparedTo(lastRunQuestion);
|
||||
lastRunQuestion == null ||
|
||||
currentQuestion.isDirtyComparedTo(lastRunQuestion);
|
||||
|
||||
return {
|
||||
result,
|
||||
@@ -40,12 +49,12 @@ export function useQueryResults(question: Question) {
|
||||
isRunnable,
|
||||
isResultDirty,
|
||||
};
|
||||
}, [question, results, metadata, lastRunQuery]);
|
||||
}, [currentQuestion, results, metadata, lastRunQuery]);
|
||||
|
||||
const handleRunQuery = useCallback(async () => {
|
||||
await runQuery();
|
||||
setLastRunQuery(question.datasetQuery());
|
||||
}, [question, runQuery]);
|
||||
setLastRunQuery(currentQuestion.datasetQuery());
|
||||
}, [currentQuestion, runQuery]);
|
||||
|
||||
const handleCancelQuery = useCallback(() => {
|
||||
return null;
|
||||
|
||||
@@ -10,7 +10,10 @@ const DEFAULT_VIZ_SETTINGS: VisualizationSettings = {
|
||||
"table.pivot": false,
|
||||
};
|
||||
|
||||
export function useQueryState(initialQuery: DatasetQuery) {
|
||||
export function useQueryState(
|
||||
initialQuery: DatasetQuery,
|
||||
proposedQuery?: DatasetQuery,
|
||||
) {
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const metadata = useSelector(getMetadata);
|
||||
|
||||
@@ -24,6 +27,30 @@ export function useQueryState(initialQuery: DatasetQuery) {
|
||||
[query, metadata],
|
||||
);
|
||||
|
||||
const proposedQuestion = useMemo(() => {
|
||||
if (!proposedQuery) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const question = Question.create({
|
||||
dataset_query: proposedQuery,
|
||||
metadata,
|
||||
});
|
||||
const { isNative } = Lib.queryDisplayInfo(question.query());
|
||||
|
||||
if (isNative) {
|
||||
const nativeQuery = question.legacyNativeQuery();
|
||||
if (nativeQuery) {
|
||||
const queryText = nativeQuery.queryText();
|
||||
// For native queries, ensure template tags are processed
|
||||
const updatedQuery = nativeQuery.setQueryText(queryText);
|
||||
return updatedQuery.question();
|
||||
}
|
||||
}
|
||||
|
||||
return question;
|
||||
}, [proposedQuery, metadata]);
|
||||
|
||||
const isQueryDirty = useMemo(
|
||||
() => !Lib.areLegacyQueriesEqual(query, initialQuery),
|
||||
[query, initialQuery],
|
||||
@@ -33,5 +60,5 @@ export function useQueryState(initialQuery: DatasetQuery) {
|
||||
setQuery(newQuestion.datasetQuery());
|
||||
};
|
||||
|
||||
return { question, isQueryDirty, setQuestion };
|
||||
return { question, proposedQuestion, isQueryDirty, setQuestion };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { PythonTransformSourceDraft } from "metabase-enterprise/transforms-python/components/PythonTransformEditor";
|
||||
import {
|
||||
SuggestedTransform,
|
||||
Transform,
|
||||
TransformSource,
|
||||
} from "metabase-types/api";
|
||||
import { useRegisterMetabotContextProvider } from "metabase/metabot";
|
||||
|
||||
export const useRegisterMetabotTransformContext = (
|
||||
transform: Transform | SuggestedTransform | undefined,
|
||||
source: TransformSource | PythonTransformSourceDraft,
|
||||
) => {
|
||||
useRegisterMetabotContextProvider(async () => {
|
||||
return {
|
||||
user_is_viewing: [{ type: "transform", ...(transform || {}), source }],
|
||||
};
|
||||
}, [transform, source]);
|
||||
};
|
||||
@@ -28,14 +28,25 @@ import type {
|
||||
TransformSource,
|
||||
} from "metabase-types/api";
|
||||
|
||||
const NEW_TRANSFORM_SCHEMA = Yup.object({
|
||||
name: Yup.string().required(Errors.required),
|
||||
description: Yup.string().nullable(),
|
||||
targetName: Yup.string().required(Errors.required),
|
||||
targetSchema: Yup.string().nullable(),
|
||||
});
|
||||
|
||||
type NewTransformValues = Yup.InferType<typeof NEW_TRANSFORM_SCHEMA>;
|
||||
|
||||
type CreateTransformModalProps = {
|
||||
source: TransformSource;
|
||||
initValues?: Partial<NewTransformValues>;
|
||||
onCreate: (transform: Transform) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function CreateTransformModal({
|
||||
source,
|
||||
initValues,
|
||||
onCreate,
|
||||
onClose,
|
||||
}: CreateTransformModalProps) {
|
||||
@@ -44,6 +55,7 @@ export function CreateTransformModal({
|
||||
<FocusTrap.InitialFocus />
|
||||
<CreateTransformForm
|
||||
source={source}
|
||||
initValues={initValues}
|
||||
onCreate={onCreate}
|
||||
onClose={onClose}
|
||||
/>
|
||||
@@ -53,26 +65,14 @@ export function CreateTransformModal({
|
||||
|
||||
type CreateTransformFormProps = {
|
||||
source: TransformSource;
|
||||
initValues?: Partial<NewTransformValues>;
|
||||
onCreate: (transform: Transform) => void;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
type NewTransformValues = {
|
||||
name: string;
|
||||
description: string | null;
|
||||
targetName: string;
|
||||
targetSchema: string | null;
|
||||
};
|
||||
|
||||
const NEW_TRANSFORM_SCHEMA = Yup.object({
|
||||
name: Yup.string().required(Errors.required),
|
||||
description: Yup.string().nullable(),
|
||||
targetName: Yup.string().required(Errors.required),
|
||||
targetSchema: Yup.string().nullable(),
|
||||
});
|
||||
|
||||
function CreateTransformForm({
|
||||
source,
|
||||
initValues,
|
||||
onCreate,
|
||||
onClose,
|
||||
}: CreateTransformFormProps) {
|
||||
@@ -100,8 +100,8 @@ function CreateTransformForm({
|
||||
const supportsSchemas = database && hasFeature(database, "schemas");
|
||||
|
||||
const initialValues: NewTransformValues = useMemo(
|
||||
() => getInitialValues(schemas),
|
||||
[schemas],
|
||||
() => getInitialValues(schemas, initValues),
|
||||
[schemas, initValues],
|
||||
);
|
||||
|
||||
if (isLoading || error != null) {
|
||||
@@ -165,12 +165,16 @@ function CreateTransformForm({
|
||||
);
|
||||
}
|
||||
|
||||
function getInitialValues(schemas: string[]): NewTransformValues {
|
||||
function getInitialValues(
|
||||
schemas: string[],
|
||||
initValues?: Partial<NewTransformValues>,
|
||||
): NewTransformValues {
|
||||
return {
|
||||
name: "",
|
||||
description: null,
|
||||
targetName: "",
|
||||
targetSchema: schemas?.[0] || null,
|
||||
...initValues,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -186,7 +190,7 @@ function getCreateRequest(
|
||||
target: {
|
||||
type: "table",
|
||||
name: targetName,
|
||||
schema: targetSchema,
|
||||
schema: targetSchema ?? null,
|
||||
database: databaseId,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { push } from "react-router-redux";
|
||||
import { match } from "ts-pattern";
|
||||
import _ from "underscore";
|
||||
|
||||
import { skipToken, useGetCardQuery } from "metabase/api";
|
||||
import { AdminSettingsLayout } from "metabase/common/components/AdminLayout/AdminSettingsLayout";
|
||||
import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper";
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import * as Urls from "metabase/lib/urls";
|
||||
import { PLUGIN_TRANSFORMS_PYTHON } from "metabase/plugins";
|
||||
import {
|
||||
getMetabotSuggestedTransform,
|
||||
setSuggestedTransform,
|
||||
} from "metabase-enterprise/metabot/state";
|
||||
import type {
|
||||
CardId,
|
||||
DatasetQuery,
|
||||
DraftTransform,
|
||||
SuggestedTransform,
|
||||
Transform,
|
||||
TransformSource,
|
||||
} from "metabase-types/api";
|
||||
@@ -19,8 +28,8 @@ import { getTransformListUrl, getTransformUrl } from "../../urls";
|
||||
|
||||
import { CreateTransformModal } from "./CreateTransformModal";
|
||||
import {
|
||||
getInitialPythonTransformSource,
|
||||
getInitialQueryTransformSource,
|
||||
type InitialTransformSource,
|
||||
getInitialTransformSource,
|
||||
} from "./utils";
|
||||
|
||||
type NewTransformPageParams = {
|
||||
@@ -40,7 +49,68 @@ type NewTransformPageProps = {
|
||||
export function NewTransformPage({ params }: NewTransformPageProps) {
|
||||
const { type, cardId } = getParsedParams(params);
|
||||
|
||||
const [source, setSource] = useState<TransformSource | null>(null);
|
||||
const {
|
||||
data: card,
|
||||
isLoading,
|
||||
error,
|
||||
} = useGetCardQuery(cardId ? { id: cardId } : skipToken);
|
||||
|
||||
const suggestedTransform = useSelector(
|
||||
getMetabotSuggestedTransform as any,
|
||||
) as ReturnType<typeof getMetabotSuggestedTransform>;
|
||||
|
||||
const canUseSuggestedTransform = match({
|
||||
type,
|
||||
suggestionSourceType: suggestedTransform?.source.type,
|
||||
})
|
||||
.with({ type: "native", suggestionSourceType: "query" }, () => true)
|
||||
.with({ type: "python", suggestionSourceType: "python" }, () => true)
|
||||
.otherwise(() => false);
|
||||
|
||||
const [initialSuggestedSource] = useState(
|
||||
canUseSuggestedTransform ? suggestedTransform?.source : undefined,
|
||||
);
|
||||
|
||||
const initialSource = cardId
|
||||
? getInitialTransformSource(card, type)
|
||||
: initialSuggestedSource || getInitialTransformSource(card, type);
|
||||
|
||||
const proposedSource = useMemo(
|
||||
() =>
|
||||
_.isEqual(suggestedTransform?.source, initialSource)
|
||||
? undefined
|
||||
: suggestedTransform?.source,
|
||||
[suggestedTransform, initialSource],
|
||||
);
|
||||
|
||||
if (isLoading || error) {
|
||||
return <LoadingAndErrorWrapper loading={isLoading} error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminSettingsLayout fullWidth>
|
||||
<NewTransformPageInner
|
||||
initialSource={initialSource}
|
||||
proposedSource={proposedSource}
|
||||
suggestedTransform={suggestedTransform}
|
||||
/>
|
||||
</AdminSettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export function NewTransformPageInner({
|
||||
initialSource,
|
||||
proposedSource,
|
||||
suggestedTransform,
|
||||
}: {
|
||||
initialSource: InitialTransformSource;
|
||||
proposedSource: TransformSource | undefined;
|
||||
suggestedTransform: SuggestedTransform | undefined;
|
||||
}) {
|
||||
const [source, setSource] = useState<TransformSource | undefined>(
|
||||
initialSource as any,
|
||||
);
|
||||
|
||||
const [isModalOpened, { open: openModal, close: closeModal }] =
|
||||
useDisclosure();
|
||||
const dispatch = useDispatch();
|
||||
@@ -54,21 +124,44 @@ export function NewTransformPage({ params }: NewTransformPageProps) {
|
||||
openModal();
|
||||
};
|
||||
|
||||
const handleChange = () => dispatch(setSuggestedTransform(undefined));
|
||||
|
||||
const handleRejectProposed = () => dispatch(setSuggestedTransform(undefined));
|
||||
|
||||
const handleCancel = () => {
|
||||
dispatch(push(getTransformListUrl()));
|
||||
handleRejectProposed();
|
||||
};
|
||||
|
||||
const createTransformInitValues = useMemo(
|
||||
() =>
|
||||
suggestedTransform
|
||||
? {
|
||||
name: suggestedTransform.name,
|
||||
description: suggestedTransform.description,
|
||||
targetName: suggestedTransform.target.name,
|
||||
// TODO: enabling this breaks the UI for some reason...
|
||||
// targetSchema: suggestedTransform.target.schema,
|
||||
}
|
||||
: undefined,
|
||||
[suggestedTransform],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<NewTransformEditorBody
|
||||
type={type}
|
||||
cardId={cardId}
|
||||
initialSource={initialSource}
|
||||
proposedSource={proposedSource}
|
||||
onChange={handleChange}
|
||||
onSave={handleSave}
|
||||
onCancel={handleCancel}
|
||||
onRejectProposed={handleRejectProposed}
|
||||
onAcceptProposed={handleSave}
|
||||
/>
|
||||
{isModalOpened && source !== null && (
|
||||
{isModalOpened && source && (
|
||||
<CreateTransformModal
|
||||
source={source}
|
||||
initValues={createTransformInitValues}
|
||||
onCreate={handleCreate}
|
||||
onClose={closeModal}
|
||||
/>
|
||||
@@ -77,54 +170,54 @@ export function NewTransformPage({ params }: NewTransformPageProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function NewTransformEditorBody(props: {
|
||||
type: DatasetQuery["type"] | "python";
|
||||
cardId?: CardId;
|
||||
interface NewTransformEditorBody {
|
||||
initialSource: InitialTransformSource;
|
||||
proposedSource?: TransformSource;
|
||||
onChange: (source: DraftTransform["source"]) => void;
|
||||
onSave: (source: TransformSource) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { type, ...rest } = props;
|
||||
if (type === "python") {
|
||||
onRejectProposed?: () => void;
|
||||
onAcceptProposed?: (query: TransformSource) => void;
|
||||
}
|
||||
|
||||
function NewTransformEditorBody({
|
||||
initialSource,
|
||||
proposedSource,
|
||||
onChange,
|
||||
onSave,
|
||||
onCancel,
|
||||
onRejectProposed,
|
||||
onAcceptProposed,
|
||||
}: NewTransformEditorBody) {
|
||||
if (initialSource.type === "python") {
|
||||
return (
|
||||
<PLUGIN_TRANSFORMS_PYTHON.TransformEditor
|
||||
initialSource={getInitialPythonTransformSource()}
|
||||
initialSource={initialSource}
|
||||
proposedSource={
|
||||
proposedSource?.type === "python" ? proposedSource : undefined
|
||||
}
|
||||
isNew
|
||||
onSave={props.onSave}
|
||||
onCancel={props.onCancel}
|
||||
onSave={onSave}
|
||||
onCancel={onCancel}
|
||||
onChange={onChange}
|
||||
onRejectProposed={onRejectProposed}
|
||||
onAcceptProposed={onAcceptProposed}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <NewQueryTransformEditorBody {...rest} type={type} />;
|
||||
}
|
||||
|
||||
function NewQueryTransformEditorBody({
|
||||
type,
|
||||
cardId,
|
||||
onSave,
|
||||
onCancel,
|
||||
}: {
|
||||
type: DatasetQuery["type"];
|
||||
cardId?: CardId;
|
||||
onSave: (source: TransformSource) => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const {
|
||||
data: card,
|
||||
isLoading,
|
||||
error,
|
||||
} = useGetCardQuery(cardId ? { id: cardId } : skipToken);
|
||||
|
||||
if (isLoading || error) {
|
||||
return <LoadingAndErrorWrapper loading={isLoading} error={error} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<QueryEditor
|
||||
initialSource={getInitialQueryTransformSource(card, type)}
|
||||
initialSource={initialSource}
|
||||
proposedSource={
|
||||
proposedSource?.type === "query" ? proposedSource : undefined
|
||||
}
|
||||
isNew
|
||||
onSave={onSave}
|
||||
onCancel={onCancel}
|
||||
onChange={onChange}
|
||||
onRejectProposed={onRejectProposed}
|
||||
onAcceptProposed={onAcceptProposed}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,30 @@
|
||||
import type { PythonTransformSourceDraft } from "metabase-enterprise/transforms-python/components/PythonTransformEditor";
|
||||
import Question from "metabase-lib/v1/Question";
|
||||
import type { Card, DatasetQuery } from "metabase-types/api";
|
||||
import type {
|
||||
Card,
|
||||
DatasetQuery,
|
||||
QueryTransformSource,
|
||||
} from "metabase-types/api";
|
||||
|
||||
export type InitialTransformSource =
|
||||
| QueryTransformSource
|
||||
| PythonTransformSourceDraft;
|
||||
|
||||
export function getInitialTransformSource(
|
||||
card: Card | undefined,
|
||||
type: DatasetQuery["type"] | "python",
|
||||
): InitialTransformSource {
|
||||
if (type === "python") {
|
||||
return getInitialPythonTransformSource();
|
||||
}
|
||||
|
||||
return getInitialQueryTransformSource(card, type);
|
||||
}
|
||||
|
||||
export function getInitialQueryTransformSource(
|
||||
card: Card | undefined,
|
||||
type: DatasetQuery["type"] | undefined,
|
||||
) {
|
||||
): QueryTransformSource {
|
||||
const query =
|
||||
card != null
|
||||
? card.dataset_query
|
||||
@@ -13,7 +33,7 @@ export function getInitialQueryTransformSource(
|
||||
return { type: "query" as const, query };
|
||||
}
|
||||
|
||||
export function getInitialPythonTransformSource() {
|
||||
export function getInitialPythonTransformSource(): PythonTransformSourceDraft {
|
||||
return {
|
||||
type: "python" as const,
|
||||
"source-database": undefined,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { t } from "ttag";
|
||||
import { skipToken } from "metabase/api";
|
||||
import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper";
|
||||
import * as Urls from "metabase/lib/urls";
|
||||
import { useRegisterMetabotContextProvider } from "metabase/metabot";
|
||||
import { PLUGIN_TRANSFORMS_PYTHON } from "metabase/plugins";
|
||||
import { Stack } from "metabase/ui";
|
||||
import { useGetTransformQuery } from "metabase-enterprise/api";
|
||||
@@ -50,6 +51,12 @@ export function TransformPage({ params }: TransformPageProps) {
|
||||
setIsPolling(isPollingNeeded(transform));
|
||||
}
|
||||
|
||||
useRegisterMetabotContextProvider(async () => {
|
||||
return transform
|
||||
? { user_is_viewing: [{ type: "transform", ...transform }] }
|
||||
: {};
|
||||
}, [transform]);
|
||||
|
||||
if (isLoading || error != null) {
|
||||
return <LoadingAndErrorWrapper loading={isLoading} error={error} />;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { push } from "react-router-redux";
|
||||
import { t } from "ttag";
|
||||
import _ from "underscore";
|
||||
|
||||
import { skipToken } from "metabase/api";
|
||||
import { AdminSettingsLayout } from "metabase/common/components/AdminLayout/AdminSettingsLayout";
|
||||
import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper";
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
import * as Urls from "metabase/lib/urls";
|
||||
import { useMetadataToasts } from "metabase/metadata/hooks";
|
||||
import { PLUGIN_TRANSFORMS_PYTHON } from "metabase/plugins";
|
||||
@@ -11,6 +13,11 @@ import {
|
||||
useGetTransformQuery,
|
||||
useUpdateTransformMutation,
|
||||
} from "metabase-enterprise/api";
|
||||
import { useMetabotAgent } from "metabase-enterprise/metabot/hooks";
|
||||
import {
|
||||
getMetabotSuggestedTransform,
|
||||
setSuggestedTransform,
|
||||
} from "metabase-enterprise/metabot/state";
|
||||
import type { Transform, TransformSource } from "metabase-types/api";
|
||||
|
||||
import { QueryEditor } from "../../components/QueryEditor";
|
||||
@@ -54,7 +61,45 @@ export function TransformQueryPageBody({
|
||||
const dispatch = useDispatch();
|
||||
const { sendErrorToast, sendSuccessToast } = useMetadataToasts();
|
||||
|
||||
const handleSourceSave = async (source: TransformSource) => {
|
||||
const metabot = useMetabotAgent();
|
||||
|
||||
const initialSource = transform.source;
|
||||
const suggestedTransform = useSelector(
|
||||
(state) => getMetabotSuggestedTransform(state, transform.id) as any,
|
||||
) as ReturnType<typeof getMetabotSuggestedTransform>;
|
||||
|
||||
// TODO: should getMetabotSuggestedTransform selector do this?
|
||||
const isPropsedSame = _.isEqual(suggestedTransform?.source, initialSource);
|
||||
const proposedSource = isPropsedSame ? undefined : suggestedTransform?.source;
|
||||
|
||||
const handleChange = () => dispatch(setSuggestedTransform(undefined));
|
||||
|
||||
const onRejectProposed = () => {
|
||||
dispatch(setSuggestedTransform(undefined));
|
||||
metabot.submitInput({
|
||||
type: "action",
|
||||
message:
|
||||
"HIDDEN MESSAGE: the user has rejected your changes, ask for clarification on what they'd like to do instead.",
|
||||
// @ts-expect-error -- TODO
|
||||
userMessage: "❌ You rejected the change",
|
||||
});
|
||||
};
|
||||
const onAcceptProposed = async (source: TransformSource) => {
|
||||
await handleSourceSave(source, { leaveEditor: false });
|
||||
dispatch(setSuggestedTransform(undefined));
|
||||
metabot.submitInput({
|
||||
type: "action",
|
||||
message:
|
||||
"HIDDEN MESSAGE: user has accepted your changes, move to the next step!",
|
||||
// @ts-expect-error -- TODO
|
||||
userMessage: "✅ You accepted the change",
|
||||
});
|
||||
};
|
||||
|
||||
const handleSourceSave = async (
|
||||
source: TransformSource,
|
||||
{ leaveEditor } = { leaveEditor: true },
|
||||
) => {
|
||||
const { error } = await updateTransform({
|
||||
id: transform.id,
|
||||
source,
|
||||
@@ -64,7 +109,9 @@ export function TransformQueryPageBody({
|
||||
sendErrorToast(t`Failed to update transform query`);
|
||||
} else {
|
||||
sendSuccessToast(t`Transform query updated`);
|
||||
dispatch(push(getTransformUrl(transform.id)));
|
||||
if (leaveEditor) {
|
||||
dispatch(push(getTransformUrl(transform.id)));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -74,22 +121,41 @@ export function TransformQueryPageBody({
|
||||
|
||||
if (transform.source.type === "python") {
|
||||
return (
|
||||
<PLUGIN_TRANSFORMS_PYTHON.TransformEditor
|
||||
<AdminSettingsLayout fullWidth key={transform.id}>
|
||||
<PLUGIN_TRANSFORMS_PYTHON.TransformEditor
|
||||
transform={transform}
|
||||
initialSource={transform.source}
|
||||
proposedSource={
|
||||
proposedSource?.type === "python" ? proposedSource : undefined
|
||||
}
|
||||
isNew={false}
|
||||
isSaving={isLoading}
|
||||
onChange={handleChange}
|
||||
onSave={handleSourceSave}
|
||||
onCancel={handleCancel}
|
||||
onRejectProposed={onRejectProposed}
|
||||
onAcceptProposed={onAcceptProposed}
|
||||
/>
|
||||
</AdminSettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AdminSettingsLayout fullWidth key={transform.id}>
|
||||
<QueryEditor
|
||||
initialSource={transform.source}
|
||||
transform={transform}
|
||||
isNew={false}
|
||||
isSaving={isLoading}
|
||||
onSave={handleSourceSave}
|
||||
onChange={handleChange}
|
||||
onCancel={handleCancel}
|
||||
proposedSource={
|
||||
proposedSource?.type === "query" ? proposedSource : undefined
|
||||
}
|
||||
onRejectProposed={onRejectProposed}
|
||||
onAcceptProposed={onAcceptProposed}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<QueryEditor
|
||||
initialSource={transform.source}
|
||||
isNew={false}
|
||||
isSaving={isLoading}
|
||||
onSave={handleSourceSave}
|
||||
onCancel={handleCancel}
|
||||
/>
|
||||
</AdminSettingsLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -39,7 +39,8 @@ type SearchContentType =
|
||||
| "table"
|
||||
| "action"
|
||||
| "indexed-entity"
|
||||
| "document";
|
||||
| "document"
|
||||
| "transform";
|
||||
|
||||
type SearchContext =
|
||||
| "search-app"
|
||||
|
||||
@@ -4,8 +4,11 @@ import type {
|
||||
CardType,
|
||||
DashboardId,
|
||||
DatasetQuery,
|
||||
DraftTransform,
|
||||
PaginationResponse,
|
||||
RowValue,
|
||||
SuggestedTransform,
|
||||
Transform,
|
||||
UnsavedCard,
|
||||
Version,
|
||||
} from ".";
|
||||
@@ -55,18 +58,6 @@ export type MetabotHistory = any[];
|
||||
|
||||
export type MetabotStateContext = Record<string, any>;
|
||||
|
||||
export type MetabotMessageReaction = {
|
||||
type: "metabot.reaction/message";
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type MetabotRedirectReaction = {
|
||||
type: "metabot.reaction/redirect";
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type MetabotReaction = MetabotMessageReaction | MetabotRedirectReaction;
|
||||
|
||||
export type MetabotColumnType =
|
||||
| "number"
|
||||
| "string"
|
||||
@@ -133,11 +124,17 @@ export type MetabotDocumentInfo = {
|
||||
id: number;
|
||||
};
|
||||
|
||||
export type MetabotTransformInfo =
|
||||
| ({ type: "transform" } & Transform) // edit
|
||||
| ({ type: "transform" } & SuggestedTransform) // edit saved suggested
|
||||
| ({ type: "transform" } & DraftTransform); // edit unsaved suggested
|
||||
|
||||
export type MetabotEntityInfo =
|
||||
| MetabotCardInfo
|
||||
| MetabotDashboardInfo
|
||||
| MetabotAdhocQueryInfo
|
||||
| MetabotDocumentInfo;
|
||||
| MetabotDocumentInfo
|
||||
| MetabotTransformInfo;
|
||||
|
||||
/* Metabot v3 - API Request Types */
|
||||
|
||||
@@ -152,7 +149,6 @@ export type MetabotAgentRequest = {
|
||||
};
|
||||
|
||||
export type MetabotAgentResponse = {
|
||||
reactions: MetabotReaction[];
|
||||
history: MetabotHistory[];
|
||||
conversation_id: string;
|
||||
state: any;
|
||||
@@ -244,3 +240,12 @@ export interface MetabotGenerateContentResponse {
|
||||
export interface PurchaseMetabotAddOnRequest {
|
||||
terms_of_service: boolean;
|
||||
}
|
||||
|
||||
/* Metabot v3 - Data Part Types */
|
||||
|
||||
export type MetabotTodoItem = {
|
||||
id: string;
|
||||
content: string;
|
||||
status: "pending" | "in_progress" | "completed" | "cancelled";
|
||||
priority: "high" | "medium" | "low";
|
||||
};
|
||||
|
||||
@@ -22,6 +22,7 @@ const ENABLED_SEARCH_MODELS = [
|
||||
"action",
|
||||
"indexed-entity",
|
||||
"document",
|
||||
"transform",
|
||||
] as const;
|
||||
|
||||
export const SEARCH_MODELS = [...ENABLED_SEARCH_MODELS, "segment"] as const;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PythonTransformSourceDraft } from "metabase-enterprise/transforms-python/components/PythonTransformEditor";
|
||||
import type { DatabaseId } from "./database";
|
||||
import type { PaginationRequest, PaginationResponse } from "./pagination";
|
||||
import type { DatasetQuery } from "./query";
|
||||
@@ -24,6 +25,13 @@ export type Transform = {
|
||||
last_run?: TransformRun | null;
|
||||
};
|
||||
|
||||
export type SuggestedTransform = Partial<Pick<Transform, "id">> &
|
||||
Pick<Transform, "name" | "description" | "source" | "target">;
|
||||
|
||||
export type DraftTransform = Partial<
|
||||
Pick<Transform, "id" | "name" | "description" | "target">
|
||||
> & { source: Transform["source"] | PythonTransformSourceDraft };
|
||||
|
||||
export type PythonTransformTableAliases = Record<string, ConcreteTableId>;
|
||||
|
||||
export type PythonTransformSource = {
|
||||
@@ -32,6 +40,7 @@ export type PythonTransformSource = {
|
||||
"source-database": DatabaseId;
|
||||
"source-tables": PythonTransformTableAliases;
|
||||
};
|
||||
|
||||
export type QueryTransformSource = {
|
||||
type: "query";
|
||||
query: DatasetQuery;
|
||||
|
||||
@@ -33,6 +33,7 @@ export const TAG_TYPES = [
|
||||
"snippet",
|
||||
"subscription",
|
||||
"subscription-channel",
|
||||
"transform",
|
||||
"table",
|
||||
"task",
|
||||
"timeline",
|
||||
@@ -62,4 +63,5 @@ export const TAG_TYPE_MAPPING = {
|
||||
metric: "card",
|
||||
snippet: "snippet",
|
||||
pulse: "subscription",
|
||||
transform: "transform",
|
||||
} as const;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type React from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import ErrorBoundary from "metabase/ErrorBoundary";
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import { PLUGIN_METABOT } from "metabase/plugins";
|
||||
import { getLocation } from "metabase/selectors/routing";
|
||||
import { Box } from "metabase/ui";
|
||||
|
||||
import { NotFound } from "../ErrorPages";
|
||||
@@ -15,9 +19,13 @@ export const AdminSettingsLayout = ({
|
||||
}: {
|
||||
sidebar?: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
maw?: string;
|
||||
fullWidth?: boolean;
|
||||
maw?: string;
|
||||
}) => {
|
||||
const location = useSelector(getLocation);
|
||||
const isMetabotEnabledForRoute =
|
||||
location.pathname.startsWith("/admin/transforms");
|
||||
|
||||
return (
|
||||
<Box className={S.Wrapper}>
|
||||
<Box className={S.Main}>
|
||||
@@ -32,11 +40,26 @@ export const AdminSettingsLayout = ({
|
||||
p={fullWidth ? 0 : "2rem"}
|
||||
>
|
||||
<Box maw={fullWidth ? undefined : maw} w="100%">
|
||||
<Box pb={fullWidth ? 0 : "2rem"}>
|
||||
<Box {...(fullWidth ? { h: "100%" } : { pb: "2rem" })}>
|
||||
<ErrorBoundary>{children ?? <NotFound />}</ErrorBoundary>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<PLUGIN_METABOT.Metabot
|
||||
hide={!isMetabotEnabledForRoute}
|
||||
config={{
|
||||
// we don't save snapshots of the metabot conversation "state" value and do not
|
||||
// revert it to the point in time of a message was sent. this will cause values
|
||||
// like the todo list to confuse the agent as there may no longer be any conversation
|
||||
// history but an in progress/complete todo list.
|
||||
preventRetryMessage: true,
|
||||
preventClose: true,
|
||||
hideSuggestedPrompts: true,
|
||||
emptyText: t`Let's transform your data together!`,
|
||||
suggestionModels: ["dataset", "transform", "table", "database"],
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -13,6 +13,7 @@ type Props = {
|
||||
lineNumbers?: boolean;
|
||||
readOnly?: boolean;
|
||||
value: string;
|
||||
proposedValue?: string;
|
||||
onChange?: (value: string) => void;
|
||||
extensions?: Extension[];
|
||||
"data-testid"?: string;
|
||||
@@ -26,6 +27,7 @@ export function CodeEditor({
|
||||
lineNumbers = true,
|
||||
readOnly,
|
||||
value,
|
||||
proposedValue,
|
||||
onChange,
|
||||
extensions: externalExtensions,
|
||||
...rest
|
||||
@@ -33,6 +35,8 @@ export function CodeEditor({
|
||||
const extensions = useExtensions({
|
||||
language,
|
||||
extensions: externalExtensions,
|
||||
originalValue: proposedValue ? value : undefined,
|
||||
proposedValue,
|
||||
});
|
||||
|
||||
return (
|
||||
@@ -47,7 +51,7 @@ export function CodeEditor({
|
||||
extensions={extensions}
|
||||
id={id}
|
||||
readOnly={readOnly}
|
||||
value={value}
|
||||
value={proposedValue ?? value}
|
||||
onChange={onChange}
|
||||
highlightRanges={highlightRanges}
|
||||
{...rest}
|
||||
|
||||
@@ -7,26 +7,38 @@ import { StreamLanguage, indentUnit } from "@codemirror/language";
|
||||
import { clojure } from "@codemirror/legacy-modes/mode/clojure";
|
||||
import { pug } from "@codemirror/legacy-modes/mode/pug";
|
||||
import { ruby } from "@codemirror/legacy-modes/mode/ruby";
|
||||
import { unifiedMergeView } from "@codemirror/merge";
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import { handlebarsLanguage as handlebars } from "@xiechao/codemirror-lang-handlebars";
|
||||
import { useMemo } from "react";
|
||||
import _ from "underscore";
|
||||
|
||||
import type { CodeLanguage } from "./types";
|
||||
|
||||
export function useExtensions({
|
||||
language,
|
||||
extensions,
|
||||
originalValue,
|
||||
proposedValue,
|
||||
}: {
|
||||
language?: CodeLanguage | Extension;
|
||||
extensions?: Extension[];
|
||||
originalValue?: string;
|
||||
proposedValue?: string;
|
||||
}) {
|
||||
return useMemo(
|
||||
() => [
|
||||
return useMemo(() => {
|
||||
const hasProposed = !!originalValue && !!proposedValue;
|
||||
|
||||
return _.compact([
|
||||
...(extensions ?? []),
|
||||
...(language ? [getLanguageExtension(language)] : []),
|
||||
],
|
||||
[language, extensions],
|
||||
);
|
||||
language && getLanguageExtension(language),
|
||||
hasProposed &&
|
||||
unifiedMergeView({
|
||||
original: originalValue,
|
||||
mergeControls: false,
|
||||
}),
|
||||
]);
|
||||
}, [language, extensions, originalValue, proposedValue]);
|
||||
}
|
||||
|
||||
export function getLanguageExtension(language: CodeLanguage | Extension) {
|
||||
|
||||
@@ -37,6 +37,12 @@ const TRANSLATED_NAME_BY_MODEL_TYPE: Record<string, string> = {
|
||||
get document() {
|
||||
return t`Document`;
|
||||
},
|
||||
get transform() {
|
||||
return t`Transform`;
|
||||
},
|
||||
get user() {
|
||||
return t`User`;
|
||||
},
|
||||
};
|
||||
|
||||
export const getTranslatedEntityName = (type: string) =>
|
||||
|
||||
@@ -219,8 +219,9 @@ export function getPrevDashAndTabs({
|
||||
filterRemovedTabs?: boolean;
|
||||
}) {
|
||||
const dashId = state.dashboardId;
|
||||
// @ts-expect-error - possibly infinite type error
|
||||
const prevDash = dashId ? state.dashboards[dashId] : null;
|
||||
const prevDash =
|
||||
// @ts-expect-error - possibly infinite type error
|
||||
dashId ? state.dashboards[dashId] : null;
|
||||
const prevTabs =
|
||||
prevDash?.tabs?.filter((t) => !filterRemovedTabs || !t.isRemoved) ?? [];
|
||||
|
||||
|
||||
@@ -9,7 +9,12 @@ import type {
|
||||
SearchModel,
|
||||
} from "metabase-types/api";
|
||||
|
||||
export type IconModel = SearchModel | CollectionItemModel | "schema";
|
||||
export type IconModel =
|
||||
| SearchModel
|
||||
| CollectionItemModel
|
||||
| "schema"
|
||||
| "transform"
|
||||
| "user";
|
||||
|
||||
export type ObjectWithModel = {
|
||||
id?: unknown;
|
||||
@@ -36,6 +41,8 @@ export const modelIconMap: Record<IconModel, IconName> = {
|
||||
metric: "metric",
|
||||
snippet: "unknown",
|
||||
document: "document",
|
||||
transform: "refresh_downstream",
|
||||
user: "person",
|
||||
};
|
||||
|
||||
export type IconData = {
|
||||
|
||||
@@ -13,4 +13,5 @@ export * from "./models";
|
||||
export * from "./pulses";
|
||||
export * from "./questions";
|
||||
export * from "./timelines";
|
||||
export * from "./transforms";
|
||||
export * from "./utils";
|
||||
|
||||
@@ -4,6 +4,7 @@ import { dashboard } from "./dashboards";
|
||||
import { document } from "./documents";
|
||||
import { metric, model } from "./models";
|
||||
import { question, tableRowsQuery } from "./questions";
|
||||
import { transform } from "./transforms";
|
||||
|
||||
export type UrlableModel = {
|
||||
id: number;
|
||||
@@ -40,6 +41,8 @@ export function modelToUrl(item: UrlableModel) {
|
||||
return document(item);
|
||||
case "user":
|
||||
return null;
|
||||
case "transform":
|
||||
return transform(item.id);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
||||
17
frontend/src/metabase/lib/urls/transforms.ts
Normal file
17
frontend/src/metabase/lib/urls/transforms.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { Transform } from "metabase-types/api";
|
||||
|
||||
export function newNativeTransform() {
|
||||
return `/admin/transforms/new/native`;
|
||||
}
|
||||
|
||||
export function newPythonTransform() {
|
||||
return `/admin/transforms/new/python`;
|
||||
}
|
||||
|
||||
export function transform(id: Transform["id"]) {
|
||||
return `/admin/transforms/${id}`;
|
||||
}
|
||||
|
||||
export function transformEdit(id: Transform["id"]) {
|
||||
return `/admin/transforms/${id}/query`;
|
||||
}
|
||||
@@ -10,10 +10,17 @@ export type ChatContextProviderFn = (
|
||||
|
||||
export type DeregisterChatContextProviderFn = () => void;
|
||||
|
||||
// internal type so we can support tiptap editor and textarea as inputs
|
||||
export type MetabotChatInputRef = {
|
||||
focus: () => void;
|
||||
scrollHeight: number;
|
||||
scrollTop: number;
|
||||
};
|
||||
|
||||
export interface MetabotContext {
|
||||
prompt: string;
|
||||
setPrompt: (prompt: string) => void;
|
||||
promptInputRef: RefObject<HTMLTextAreaElement> | undefined;
|
||||
promptInputRef: RefObject<MetabotChatInputRef> | undefined;
|
||||
|
||||
getChatContext: () => Promise<MetabotChatContext>;
|
||||
registerChatContextProvider: (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user