Compare commits

...

75 Commits

Author SHA1 Message Date
Sloan Sparger
e99c68e9cc handle change on python transform context 2025-10-01 13:01:21 -05:00
Sloan Sparger
fa3911cbff TEMP COMMIT 2025-10-01 12:34:36 -05:00
Sloan Sparger
d85ea68a4f quick fixes 2025-10-01 10:38:56 -05:00
Sloan Sparger
8545180dc5 fix @mention recents in documents, update suggestion sidebar component to not accept / reject items + calculate isViewing better 2025-10-01 09:11:39 -05:00
Sloan Sparger
2071b499ab make suggestion models configurable for a Metabot instance 2025-10-01 09:11:39 -05:00
Noah Moss
42f7f91247 Add transforms to search for Metabot (#64134)
* index transforms content

* Add support for transforms & query search in metabot search tool API

* fixup whitespace

* update to use new :fields syntax

* Fix up serach-native-query malli types

* Fix the 15-day interval for transforms to be db-agnostic

* move engine loading to metabase.search.init to break circular dep

* fix kondo

* Revert "Fix the 15-day interval for transforms to be db-agnostic"

This reverts commit 11b34e4cd4a8a5a0cf8041a1d82d487ed8c81025.

* add transform to set of appdb scorer models; unrevert db type fix

* fix kondo

* implement requisite multimethods for transforms not to error in in-place search engine

* ensure we enforce metabot/unknown contexts for transforms in in-place search backend as well

* fix filter test

* transform->postprocess to reduce naming ambiguity; add a basic transform ingestion test

* add a metabot result postprocessing test

* add transforms scoring test

* expand ingestion tests

* fix tests + kondo

* address feedback
2025-09-30 16:58:19 -04:00
Sloan Sparger
c7ab945216 fix type issues and few small feedback things 2025-09-30 13:12:54 -05:00
Sloan Sparger
11e7f8f792 fix search bug 2025-09-30 09:53:43 -05:00
Sloan Sparger
06187e258c @mention solution that plays nicely with documents 2025-09-29 16:37:30 -05:00
Sloan Sparger
1521f87c35 prevent rewinding conversation for admin pages 2025-09-29 13:12:56 -05:00
Sloan Sparger
9746d26262 lots of fe clean up 2025-09-29 12:56:21 -05:00
Noah Moss
bde75ec8cb Merge branch 'master' into transforms-codegen 2025-09-29 12:33:12 -04:00
Mike Appleby
c386f5dc06 Move native-query-transform? into transforms.util and re-use existing utils
metabot-v3.tools.transforms defined a couple of helper functions that were nearly identical to ones that have since
been added to transforms.util ns. Move some things around and make use of the existing transforms.util predicates,
which are now exported from the module's core namespace.
2025-09-26 16:34:42 -07:00
Mike Appleby
db9915dc19 Add transforms-python to module deps for metabot 2025-09-26 16:32:49 -07:00
Noah Moss
96c5218a0e Conditionally add transforms to model->db-model 2025-09-26 15:42:07 -04:00
Mike Appleby
5e512b1870 Merge remote-tracking branch 'origin/master' into transforms-codegen 2025-09-26 12:23:29 -07:00
Sloan Sparger
d093af8894 first pass at nested @mentions 2025-09-26 13:38:42 -05:00
Noah Moss
7e54498224 improve ranking of transforms in search by hardcoding a last_viewed_at time in user-recency-expr 2025-09-26 10:08:59 -04:00
Sloan Sparger
83ecd08c47 cleaner code for todos 2025-09-25 23:18:42 -05:00
Sloan Sparger
bb30961bab smaller todos 2025-09-25 22:54:17 -05:00
Sloan Sparger
0176fb79c3 better diff colors 2025-09-25 20:53:26 -05:00
Sloan Sparger
fc63da673f fix accept in sidebar not saving 2025-09-25 20:04:40 -05:00
Sloan Sparger
83212f5a87 clear proposed on cancel 2025-09-25 19:37:03 -05:00
Sloan Sparger
d47c329ed7 python fe improvements + edit experience now works 2025-09-25 19:29:46 -05:00
Sloan Sparger
4c3aaac096 python FE progress 2025-09-25 16:58:26 -05:00
Sloan Sparger
78e3516d32 some type fixes 2025-09-25 16:58:26 -05:00
Noah Moss
4b483e8ac4 surface transforms in @-mention search 2025-09-25 17:07:37 -04:00
Mike Appleby
025f903439 Enhance context with :used_tables for python transforms 2025-09-25 12:55:17 -07:00
Mike Appleby
47f82c8a77 Add get-transform-python-library-details metabot tool api endpoint 2025-09-25 12:55:17 -07:00
Mike Appleby
9599fc0116 Ensure transform :source and :target keys get snake cased when returned from metabot tool apis 2025-09-25 12:44:06 -07:00
Mike Appleby
697426d297 Update metabot tools test with basic python transform tests 2025-09-25 12:25:32 -07:00
Mike Appleby
942643a1a8 Fix metabot get-transforms api test to :post not :get 2025-09-25 12:25:16 -07:00
Sloan Sparger
21870b3bdf Merge branch 'master' into transforms-codegen 2025-09-25 14:02:46 -05:00
Sloan Sparger
7cf553aee0 cleaner, more copy/pastable, better flowing @mentions in metabot 2025-09-25 08:51:18 -05:00
Noah Moss
29b6ee74a8 Ensure we parse template tags for transform edits in Metabot sidebar (#64001) 2025-09-24 17:12:44 -04:00
Tyler Plude
b4b7907cc6 further logging 2025-09-24 11:35:37 -05:00
Tyler Plude
18e00b770c debug logging for search table bug 2025-09-24 11:35:37 -05:00
Sloan Sparger
5fb62d64b7 good'nuff @mentions 2025-09-24 11:35:36 -05:00
Sloan Sparger
cd507e20a3 better suggestion chat ui 2025-09-24 11:33:15 -05:00
Sloan Sparger
8c62c354ad crude sidebar ui for query transforms diffs 2025-09-24 11:33:15 -05:00
Sloan Sparger
0ae57508ec even better todo list ui 2025-09-24 11:33:15 -05:00
Sloan Sparger
8fc39832c8 better todos 2025-09-24 11:29:56 -05:00
Noah Moss
20f5fa2761 ensure proposed queries go through nativeQuery.setQueryText so that template tags are parsed 2025-09-24 11:29:56 -05:00
Noah Moss
5dce588375 Add basic snippets tools 2025-09-24 11:29:55 -05:00
Sloan Sparger
e92e9017b9 hide suggested prompts 2025-09-24 11:10:32 -05:00
Sloan Sparger
4515a136ff hack - better emtpy convo text 2025-09-24 11:10:32 -05:00
Sloan Sparger
874cd510b5 better hidden message ui 2025-09-24 11:10:32 -05:00
Sloan Sparger
7dec13db6c copy change for create transform buttons in chat 2025-09-24 11:10:32 -05:00
Mihael Konjevic
a840799f61 Change button label to "Proposed changes to <transform>" 2025-09-24 11:10:32 -05:00
Sloan Sparger
96b217181c temp - make metabot always visible on transforms admin pages 2025-09-24 11:10:32 -05:00
Sloan Sparger
c7831044e2 persist diff of transform to the conversation history 2025-09-24 11:10:31 -05:00
Noah Moss
c84d7977e8 Ensure we have a fallback for context items that don't have SQL to parse 2025-09-24 11:10:31 -05:00
Sloan Sparger
8fb2f8af83 handle new transforms better 2025-09-24 11:10:31 -05:00
Mihael Konjevic
45206f30b5 Show 'Apply Changes' button when a transform change is generated by the agent 2025-09-24 11:10:31 -05:00
Mike Appleby
b2db725412 Switch /ee/metabot-v3/get-transforms from :get to :post
This endpoint doesn't expect args of it's own in the body, but does accept the standard metabot conversation_id that
it echoes back.
2025-09-24 11:10:31 -05:00
Sloan Sparger
f089029b69 half hacky way to use current editor state for context in transform editing 2025-09-24 11:10:31 -05:00
Sloan Sparger
5215a1f04b fix transform context on subsequent edits for the same transform 2025-09-24 11:10:31 -05:00
Sloan Sparger
ea7cc08248 misc. fe improvements 2025-09-24 11:10:29 -05:00
Sloan Sparger
fc9ed44ef4 quick fix preview error not displaying correctly 2025-09-24 11:03:23 -05:00
Sloan Sparger
6b50f2284a hack in a message to metabot on save 2025-09-24 11:03:23 -05:00
Sloan Sparger
30b0e76808 wip on creation flow 2025-09-24 11:03:23 -05:00
Noah Moss
625d9c357c Parse out tables from transforms in metabot user context 2025-09-24 11:03:23 -05:00
Sloan Sparger
5aed907492 use transforms instead of data queries 2025-09-24 11:03:23 -05:00
Sloan Sparger
b991bc7052 add transform context on edit query page 2025-09-24 11:03:23 -05:00
Mike Appleby
add9cea204 Cleanup malli schemas for get-transforms and get-transform-details 2025-09-24 11:03:23 -05:00
Sloan Sparger
d27be47233 accept tranform suggestions from metabot 2025-09-24 11:03:23 -05:00
Sloan Sparger
abf38d93bd show metabot on edit + create transforms page 2025-09-24 11:03:23 -05:00
Sloan Sparger
78b7f95c52 send transform in context 2025-09-24 11:03:23 -05:00
Sloan Sparger
90a338f8a7 use transforms_codegen profile for metabot on transforms page 2025-09-24 11:03:23 -05:00
Sloan Sparger
4b8bd6e61f allow opening metabot on transforms admin pages 2025-09-24 11:03:23 -05:00
Mike Appleby
0934b50409 Add /ee/metabot-tools/get-transform-details endpoint 2025-09-24 11:03:23 -05:00
Mike Appleby
5e095f519b Add test that get-transforms does not include non-native MBQL transforms 2025-09-24 11:03:23 -05:00
Mike Appleby
ba34ffd9ca Add /ee/metabot-tools/get-transforms endpoint
Returns a sequence of all python or native query transforms.

BOT-391 [Backend] Create transform index tool API
2025-09-24 11:03:21 -05:00
Sloan Sparger
5cf33d5aef FE - add codemirror diffing, add diff support to native query editor, fake a proposed query 2025-09-24 10:57:29 -05:00
Sloan Sparger
b7ee9cb338 init branch 2025-09-24 10:57:28 -05:00
125 changed files with 3628 additions and 799 deletions

View File

@@ -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

View File

@@ -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]

View File

@@ -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))

View File

@@ -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.

View File

@@ -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]

View File

@@ -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))

View File

@@ -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))))

View File

@@ -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))))

View File

@@ -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))

View File

@@ -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

View File

@@ -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?])

View File

@@ -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}})

View File

@@ -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]

View File

@@ -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]]

View File

@@ -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)

View File

@@ -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
;; ======================================

View File

@@ -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)))))))

View File

@@ -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"

View File

@@ -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})))))))))

View File

@@ -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)))))))))

View File

@@ -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)))))))))

View File

@@ -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}

View File

@@ -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(),

View File

@@ -19,7 +19,6 @@ export const ENTERPRISE_TAG_TYPES = [
"gsheets-status",
"document",
"comment",
"transform",
"transform-tag",
"transform-job",
"transform-job-via-tag",

View File

@@ -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>
);

View File

@@ -1,8 +1,4 @@
import type { SearchModel } from "metabase-types/api";
export interface CardEmbedRef {
id: number;
name?: string;
}
export type SuggestionModel = SearchModel | "user";

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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>
);
};

View File

@@ -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;
}
}

View File

@@ -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();
}

View File

@@ -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;
}

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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";

View File

@@ -0,0 +1 @@
export * from "./MetabotChatEditor";

View File

@@ -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: [] }],
};
}

View File

@@ -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" },
},
],
},
],
});
});
});
});

View File

@@ -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);
}}
/>
),
)}

View File

@@ -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>
);
};

View File

@@ -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`;
},
};

View File

@@ -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());

View File

@@ -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,
};
};

View File

@@ -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("");

View File

@@ -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/),

View File

@@ -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());
};
};

View File

@@ -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,
};

View File

@@ -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 }));
};
};

View File

@@ -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,
}),
);
};
};

View File

@@ -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>;

View File

@@ -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));
};

View File

@@ -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;
},

View File

@@ -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;
},
);

View File

@@ -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",
});
});
});
});

View File

@@ -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})`;
};

View File

@@ -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
/>
) : (
<>

View File

@@ -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}
/>
);
});
};

View File

@@ -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();
}
},

View File

@@ -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;
}

View File

@@ -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) {

View File

@@ -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}
/>
)}
</>
)}
</>
);

View File

@@ -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",

View File

@@ -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;

View File

@@ -0,0 +1,3 @@
import type { SearchModel } from "metabase-types/api";
export type SuggestionModel = SearchModel | "transform" | "user";

View File

@@ -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,
};
}

View File

@@ -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,

View File

@@ -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({

View File

@@ -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}

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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`;
}
}

View File

@@ -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}

View File

@@ -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;

View File

@@ -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 };
}

View File

@@ -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]);
};

View File

@@ -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,
},
};

View File

@@ -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}
/>
);
}

View File

@@ -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,

View File

@@ -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} />;
}

View File

@@ -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>
);
}

View File

@@ -39,7 +39,8 @@ type SearchContentType =
| "table"
| "action"
| "indexed-entity"
| "document";
| "document"
| "transform";
type SearchContext =
| "search-app"

View File

@@ -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";
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>
);

View File

@@ -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}

View File

@@ -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) {

View File

@@ -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) =>

View File

@@ -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) ?? [];

View File

@@ -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 = {

View File

@@ -13,4 +13,5 @@ export * from "./models";
export * from "./pulses";
export * from "./questions";
export * from "./timelines";
export * from "./transforms";
export * from "./utils";

View File

@@ -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;
}

View 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`;
}

View File

@@ -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