Compare commits

..

8 Commits

Author SHA1 Message Date
Romeo Van Snick
8c72149945 Rename docker-compose.yml to compose.yml for better compatibility with python-runner repo 2025-09-29 21:08:08 +10:00
Romeo Van Snick
350e69e4e9 Add RUN_MODE to python-runner 2025-09-29 21:08:08 +10:00
Romeo Van Snick
ad9e3af2cd Disable limits in local dev 2025-09-29 21:08:08 +10:00
Romeo Van Snick
9bc2f603e2 Remove python runner dir in CI 2025-09-29 21:08:08 +10:00
Romeo Van Snick
b9e1f5774d Set up the python runner bucket 2025-09-29 21:08:07 +10:00
Romeo Van Snick
0e93fb8dbc Add missing setting 2025-09-29 21:08:07 +10:00
Romeo Van Snick
92e9305d20 Import python runner in Cypress docker compose file 2025-09-29 21:08:07 +10:00
Romeo Van Snick
df4a0869b5 Add docker-compose file for python-runner 2025-09-29 21:08:07 +10:00
168 changed files with 1047 additions and 4194 deletions

View File

@@ -1826,7 +1826,6 @@
lib
lib-be
models
native-query-snippets
permissions
premium-features
pulse
@@ -1842,9 +1841,7 @@
util
warehouse-schema
warehouses
enterprise/documents
enterprise/transforms
enterprise/transforms-python}}
enterprise/documents}}
enterprise/permission-debug
{:team "UX West"
@@ -2016,7 +2013,6 @@
premium-features
query-processor
request
search
settings
sync
task

View File

@@ -19,6 +19,7 @@ while [[ $# -gt 0 ]]; do
done
echo "Cloning python-runner repository..."
rm -r python-runner
git clone --depth 1 https://${METABASE_AUTOMATION_USER_TOKEN}@github.com/metabase/python-runner-container.git python-runner
if [ "$SKIP_START" = false ]; then

View File

@@ -146,8 +146,6 @@ To define the configuration that applies to every embed on the page, use the `de
- `apiKey: mb_YourAPIKey` (optional, for development only) - another way to preview embeds locally using an API key.
- `fetchRequestToken: () => Promise<{ jwt: string }>` (optional) - you can customize how the SDK fetches the refresh token for JWT authentication by specifying the `fetchRequestToken` function. See [customizing JWT authentication](./sdk/authentication.md#customizing-jwt-authentication).
### Theming
You can specify colors, fonts, spacing, and other appearance options using the `theme` parameter in your embed configuration.

View File

@@ -89,7 +89,7 @@ You can add some middleware in your backend to handle cross-domain requests.
## Customizing JWT authentication
You can customize how the SDK fetches the request token by specifying the `fetchRequestToken` function with the `defineMetabaseAuthConfig` function:
You can customize how the SDK fetches the refresh token by specifying the `fetchRefreshToken` function with the `defineMetabaseAuthConfig` function:
```typescript
{% include_file "{{ dirname }}/snippets/authentication/auth-config-jwt.tsx" snippet="example" %}

View File

@@ -24,7 +24,6 @@ export interface BaseEmbedTestPageOptions {
instanceUrl?: string;
apiKey?: string;
useExistingUserSession?: boolean;
fetchRequestToken?: () => Promise<{ jwt: string }>;
theme?: MetabaseTheme;
preferredAuthMethod?: "jwt" | "saml";
locale?: string;
@@ -40,7 +39,7 @@ export interface BaseEmbedTestPageOptions {
afterEmbed?: string;
};
onVisitPage?(win: Cypress.AUTWindow): void;
onVisitPage?(): void;
}
export interface MetabaseElement {
@@ -268,7 +267,14 @@ export const getNewEmbedConfigurationScript = ({
useExistingUserSession,
preferredAuthMethod,
locale,
}: BaseEmbedTestPageOptions["metabaseConfig"] = {}) => {
}: {
instanceUrl?: string;
theme?: MetabaseTheme;
apiKey?: string;
useExistingUserSession?: boolean;
preferredAuthMethod?: "jwt" | "saml";
locale?: string;
} = {}) => {
const config = {
instanceUrl,
apiKey,

View File

@@ -11,19 +11,6 @@ import {
} from "e2e/support/helpers";
import { enableJwtAuth } from "e2e/support/helpers/e2e-jwt-helpers";
export const getSignedJwtForUser = async (user = USERS.admin) => {
const secret = new TextEncoder().encode(JWT_SHARED_SECRET);
return new jose.SignJWT({
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
exp: Math.round(Date.now() / 1000) + 60 * 10, // 10 minutes expiration
})
.setProtectedHeader({ alg: "HS256" })
.sign(secret);
};
export const mockAuthProviderAndJwtSignIn = (user = USERS.admin) => {
cy.intercept("GET", `${AUTH_PROVIDER_URL}**`, async (req) => {
try {
@@ -39,7 +26,15 @@ export const mockAuthProviderAndJwtSignIn = (user = USERS.admin) => {
return;
}
const jwt = await getSignedJwtForUser(user);
const secret = new TextEncoder().encode(JWT_SHARED_SECRET);
const jwt = await new jose.SignJWT({
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
exp: Math.round(Date.now() / 1000) + 60 * 10, // 10 minutes expiration
})
.setProtectedHeader({ alg: "HS256" })
.sign(secret);
req.reply({
statusCode: 200,

View File

@@ -1036,7 +1036,7 @@ describe("scenarios > admin > datamodel", () => {
column: "New tax",
values: ["2.07", "6.1", "2.9", "6.01", "7.03"],
});
verifyObjectDetailPreview({ rowNumber: 4, row: ["New tax", "2.07"] });
verifyObjectDetailPreview({ index: 4, row: ["New tax", "2.07"] });
cy.log("verify viz");
H.openOrdersTable();
@@ -1429,7 +1429,7 @@ describe("scenarios > admin > datamodel", () => {
column: "New tax",
values: ["2.07", "6.1", "2.9", "6.01", "7.03"],
});
verifyObjectDetailPreview({ rowNumber: 4, row: ["New tax", "2.07"] });
verifyObjectDetailPreview({ index: 4, row: ["New tax", "2.07"] });
cy.log("verify viz");
H.openOrdersTable();
@@ -1571,7 +1571,7 @@ describe("scenarios > admin > datamodel", () => {
values: ["14", "123", "105", "94", "132"],
});
verifyObjectDetailPreview({
rowNumber: 2,
index: 2,
row: ["Remapped Product ID", "14"],
});
@@ -1669,7 +1669,7 @@ describe("scenarios > admin > datamodel", () => {
],
});
verifyObjectDetailPreview({
rowNumber: 4,
index: 4,
row: ["Rating", "December 31, 1969, 4:00 PM"],
});
@@ -1718,7 +1718,7 @@ describe("scenarios > admin > datamodel", () => {
],
});
verifyObjectDetailPreview({
rowNumber: 4,
index: 4,
row: ["Rating", "December 31, 1969, 4:00 PM"],
});
@@ -1989,8 +1989,8 @@ describe("scenarios > admin > datamodel", () => {
values: ["2.07", "6.10", "2.90", "6.01", "7.03"],
});
verifyObjectDetailPreview({
rowNumber: 4,
row: ["Tax ($)", "2.07"],
index: 4,
row: ["Tax ($)", "$2.07"],
});
cy.log("change currency");
@@ -2009,8 +2009,8 @@ describe("scenarios > admin > datamodel", () => {
values: ["2.07", "6.10", "2.90", "6.01", "7.03"],
});
verifyObjectDetailPreview({
rowNumber: 4,
row: ["Tax (CA$)", "2.07"],
index: 4,
row: ["Tax (CA$)", "CA$2.07"],
});
cy.log("verify viz");
@@ -2175,7 +2175,7 @@ describe("scenarios > admin > datamodel", () => {
values: ["2.07", "6.1", "2.9", "6.01", "7.03"],
});
verifyObjectDetailPreview({
rowNumber: 4,
index: 4,
row: ["Tax", "2.07"],
});
@@ -2293,7 +2293,7 @@ describe("scenarios > admin > datamodel", () => {
.should("be.visible");
cy.get("@dataset.all").should("have.length", 0);
verifyObjectDetailPreview({
rowNumber: 4,
index: 4,
row: ["Tax", "2.07"],
});
@@ -2509,7 +2509,7 @@ describe("scenarios > admin > datamodel", () => {
values: ["14", "123", "105", "94", "132"],
});
verifyObjectDetailPreview({
rowNumber: 2,
index: 2,
row: ["Product ID", "14"],
});
@@ -2529,7 +2529,7 @@ describe("scenarios > admin > datamodel", () => {
cy.log("verify preview");
verifyObjectDetailPreview({
rowNumber: 2,
index: 2,
row: ["Product ID", "Awesome Concrete Shoes"],
});
verifyTablePreview({
@@ -2670,7 +2670,7 @@ describe("scenarios > admin > datamodel", () => {
cy.log("verify preview");
FieldSection.getPreviewButton().click();
verifyObjectDetailPreview({
rowNumber: 2,
index: 2,
row: ["Product ID", "Awesome Concrete Shoes"],
});
verifyTablePreview({
@@ -2755,7 +2755,7 @@ describe("scenarios > admin > datamodel", () => {
],
});
verifyObjectDetailPreview({
rowNumber: 3,
index: 3,
row: ["Rating", "Perfecto"],
});
@@ -2846,10 +2846,6 @@ describe("scenarios > admin > datamodel", () => {
'You can only use custom mapping for numerical fields with filtering set to "A list of all values"',
);
cy.log("close popover by clicking on element inside panel");
FieldSection.get().findByText("Field settings").click();
cy.log("open popover");
FieldSection.getFilteringInput().click();
H.popover().findByText("A list of all values").click();
cy.wait("@updateField");
@@ -2902,7 +2898,7 @@ describe("scenarios > admin > datamodel", () => {
],
});
verifyObjectDetailPreview({
rowNumber: 1,
index: 1,
row: ["User ID", "2023-10-07T01:34:35.462-07:00"],
});
@@ -2956,7 +2952,7 @@ describe("scenarios > admin > datamodel", () => {
values: ["10", "10"],
});
verifyObjectDetailPreview({
rowNumber: 1,
index: 1,
row: ["Json → A", "10"],
});
@@ -3111,7 +3107,7 @@ describe("scenarios > admin > datamodel", () => {
values: ["200%", "300%", "200%", "600%", "500%"],
});
verifyObjectDetailPreview({
rowNumber: 8,
index: 8,
row: ["Quantity", "200%"],
});
});
@@ -3181,7 +3177,7 @@ describe("scenarios > admin > datamodel", () => {
values: ["about 2", "about 3", "about 2", "about 6", "about 5"],
});
verifyObjectDetailPreview({
rowNumber: 8,
index: 8,
row: ["Quantity", "about 2"],
});
@@ -3869,28 +3865,31 @@ function verifyTablePreview({
}
function verifyObjectDetailPreview({
rowNumber,
index,
row,
}: {
rowNumber: number;
index: number;
row: [string, string];
}) {
const [label, value] = row;
const labelIndex = index * 2;
const valueIndex = labelIndex + 1;
PreviewSection.getPreviewTypeInput().findByText("Detail").click();
cy.wait("@dataset");
cy.findAllByTestId("column-name").then(($els) => {
const foundRowIndex = $els
.toArray()
.findIndex((el) => el.textContent?.trim() === label);
cy.findAllByTestId("object-details-table-cell").should((elements) => {
const index = [...elements].findIndex(
(element) => element.textContent?.trim() === label,
);
expect(index).to.eq(labelIndex);
});
expect(rowNumber).to.eq(foundRowIndex);
cy.findAllByTestId("value")
.should("have.length.gte", foundRowIndex)
.eq(foundRowIndex)
.should("contain", value);
cy.findAllByTestId("object-details-table-cell").should((elements) => {
const index = [...elements].findIndex(
(element) => element.textContent?.trim() === value,
);
expect(index).to.eq(valueIndex);
});
}

View File

@@ -2,6 +2,7 @@ name: metabase-e2e
include:
- ../../../snowplow/docker-compose.yml
- ../../../python-runner/compose.yaml
services:
postgres-sample:

View File

@@ -1,7 +1,5 @@
import { USERS } from "e2e/support/cypress_data";
import { ORDERS_DASHBOARD_ID } from "e2e/support/cypress_sample_instance_data";
import {
getSignedJwtForUser,
mockAuthSsoEndpointForSamlAuthProvider,
stubWindowOpenForSamlPopup,
} from "e2e/support/helpers/embedding-sdk-testing";
@@ -126,62 +124,6 @@ describe("scenarios > embedding > sdk iframe embedding > authentication", () =>
assertDashboardLoaded(frame);
});
it("can login via JWT and a custom fetch request token function", () => {
H.prepareSdkIframeEmbedTest({ enabledAuthMethods: ["jwt"], signOut: true });
const frame = H.loadSdkIframeEmbedTestPage({
onVisitPage: (win) => {
(win as any).metabaseConfig = {
...(win as any).metabaseConfig,
fetchRequestToken: async () => {
const jwt = await getSignedJwtForUser(USERS.admin);
return { jwt };
},
};
},
elements: [
{
component: "metabase-dashboard",
attributes: {
dashboardId: ORDERS_DASHBOARD_ID,
},
},
],
});
assertDashboardLoaded(frame);
});
it("shows error message if login via JWT and a custom fetch request token is failing", () => {
H.prepareSdkIframeEmbedTest({ enabledAuthMethods: ["jwt"], signOut: true });
const frame = H.loadSdkIframeEmbedTestPage({
onVisitPage: (win) => {
(win as any).metabaseConfig = {
...(win as any).metabaseConfig,
fetchRequestToken: async () => {
return { jwt: "" };
},
};
},
elements: [
{
component: "metabase-dashboard",
attributes: {
dashboardId: ORDERS_DASHBOARD_ID,
},
},
],
});
frame.within(() => {
cy.findByTestId("sdk-error-container")
.findByText(/Failed to fetch JWT token/)
.should("exist");
});
});
it("can login via SAML", () => {
mockAuthSsoEndpointForSamlAuthProvider();
H.prepareSdkIframeEmbedTest({ enabledAuthMethods: [], signOut: true });

View File

@@ -689,7 +689,7 @@ describe("issue 23421", () => {
type: "model",
};
const hiddenColumnsModelDetails = {
const hiddenColumnsQuestionDetails = {
native: {
query,
},
@@ -734,17 +734,16 @@ describe("issue 23421", () => {
});
it("`visualization_settings` with hidden columns should not break UI (metabase#23421)", () => {
H.createNativeQuestion(hiddenColumnsModelDetails, {
H.createNativeQuestion(hiddenColumnsQuestionDetails, {
visitQuestion: true,
});
H.openQuestionActions();
H.popover().findByText("Edit query definition").click();
H.NativeEditor.get().should("be.visible").and("contain", query);
H.tableInteractiveHeader().within(() => {
cy.findByText("id").should("be.visible");
cy.findByText("created_at").should("be.visible");
});
cy.findByTestId("visualization-root")
.findByText("Every field is hidden right now")
.should("be.visible");
cy.button("Save changes").should("be.disabled");
});
});

View File

@@ -69,6 +69,7 @@
"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,18 +54,6 @@
: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."
@@ -82,48 +70,19 @@
(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, SQL transforms, and Python transforms"
"Enhance context by adding table schema information for native queries"
[context]
(if-let [user-viewing (get context :user_is_viewing)]
(let [enhanced-viewing
(mapv (fn [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))
(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))
user-viewing)]
(assoc context :user_is_viewing enhanced-viewing))
context))

View File

@@ -152,15 +152,6 @@
(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.
@@ -184,26 +175,17 @@
:db_id database-id
:active true
:visibility_type nil
(cond-> {:where (visible-filter-clause)
(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})
: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,8 +19,6 @@
[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]
@@ -621,66 +619,6 @@
[: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
@@ -690,36 +628,6 @@
[: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
@@ -941,54 +849,6 @@
(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
@@ -1041,14 +901,13 @@
(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" "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)]]]]
[: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)]]]]
[:map {:encode/tool-api-request
#(update-keys % (comp keyword u/->kebab-case-en))}]])
@@ -1106,35 +965,6 @@
(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,11 +6,10 @@
[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" "transform"})
#{"table" "dataset" "card" "dashboard" "metric" "database"})
(def ^:private search-model-mappings
"Maps metabot entity types to search engine model types"
@@ -25,18 +24,18 @@
[search-model]
(get (set/map-invert search-model-mappings) search-model search-model))
(defn- postprocess-search-result
(defn- transform-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
@@ -50,18 +49,14 @@
"dashboard"
(merge common-fields
{:verified verified?
:collection collection-info})
"transform"
(merge common-fields
{:database_id (:database_id result)})
{:verified verified?
:collection collection-info})
;; 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."
@@ -103,21 +98,10 @@
"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 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})
entity-types limit metabot-id]}]
(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
@@ -139,24 +123,13 @@
:context :metabot
:archived false
:limit (or limit 50)
:offset 0
:search-native-query (boolean search-native-query)}
:offset 0}
(when use-verified-content?
{:verified true})))
_ (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))
search-results (search/search search-context)]
(:data search-results)))
;; Create futures for parallel execution
futures (mapv #(future (search-fn %)) all-queries)
result-lists (mapv deref futures)
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))
fused-results (reciprocal-rank-fusion result-lists)]
(map transform-search-result fused-results)))

View File

@@ -1,25 +0,0 @@
(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

@@ -1,40 +0,0 @@
(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,7 +1,6 @@
(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]
@@ -176,13 +175,11 @@
(into #{} (map name activity-feed/rv-models)))
(def ^:private appdb-scorer-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)))
(into recent-views-models (map name search.scoring/bookmarked-models-and-sub-models)))
(comment
(require '[metabase.search.spec :as search.spec])
(require '[clojure.set :as set]
'[metabase.search.spec :as search.spec])
;; #{"segment" "database" "action" "indexed-entity"}
(set/difference (set search.spec/search-models) appdb-scorer-models))

View File

@@ -16,7 +16,9 @@
[metabase.sso.core :as sso]
[metabase.util.i18n :refer [tru]]
[ring.util.response :as response]
[toucan2.core :as t2]))
[toucan2.core :as t2])
(:import
(java.net URLEncoder)))
(set! *warn-on-reflection* true)
@@ -97,7 +99,7 @@
(defn- session-data
[jwt {{redirect :return_to} :params, :as request}]
(let [redirect-url (or redirect "/")]
(let [redirect-url (or redirect (URLEncoder/encode "/"))]
(sso-utils/check-sso-redirect redirect-url)
(let [jwt-data (try
(jwt/unsign jwt (sso-settings/jwt-shared-secret)

View File

@@ -85,9 +85,14 @@
(api/check (transforms.util/check-feature-enabled transform)
[402 (deferred-tru "Premium features required for this transform type are not enabled.")]))
(defn get-transforms
(api.macros/defendpoint :get "/"
"Get a list of transforms."
[& {:keys [last_run_start_time last_run_statuses tag_ids]}]
[_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)]]]]
(api/check-superuser)
(let [transforms (t2/select :model/Transform)]
(into []
@@ -97,16 +102,6 @@
(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
@@ -134,9 +129,11 @@
;; Return with hydrated tag_ids
(t2/hydrate transform :transform_tag_ids))))
(defn get-transform
(api.macros/defendpoint :get "/:id"
"Get a specific transform."
[id]
[{:keys [id]} :- [:map
[:id ms/PositiveInt]]]
(log/info "get transform" 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)]
@@ -145,13 +142,6 @@
(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,13 +2,8 @@
"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]
[metabase-enterprise.transforms.util
native-query-transform?
python-transform?
query-transform?])
transform-timeout])

View File

@@ -5,8 +5,6 @@
[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]))
@@ -18,11 +16,6 @@
(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
@@ -159,39 +152,3 @@
(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,7 +8,6 @@
[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]
@@ -42,12 +41,6 @@
[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,19 +6,14 @@
[metabase.api.routes.common :refer [+auth]]
[metabase.api.util.handlers :as handlers]))
(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/check-superuser)
(-> (python-library/get-python-library-by-path path)
api/check-404
(select-keys [:source :path :created_at :updated_at])))
(api.macros/defendpoint :put "/library/:path"
"Update the Python library source code for user modules."

View File

@@ -133,46 +133,6 @@
(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,54 +206,6 @@
(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,126 +1110,3 @@
(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 postprocess-search-result-test
(testing "table result postprocessing"
(deftest transform-search-result-test
(testing "table result transformation"
(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/postprocess-search-result result)))))
(is (= expected (#'search/transform-search-result result)))))
(testing "model (dataset) result postprocessing"
(testing "model (dataset) result transformation"
(let [result {:model "dataset"
:id 2
:name "Sales Model"
@@ -145,26 +145,9 @@
:collection {}
:updated_at "2024-01-02"
:created_at "2024-01-02"}]
(is (= expected (#'search/postprocess-search-result result)))))
(is (= expected (#'search/transform-search-result result)))))
(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"
(testing "dashboard result transformation"
(let [result {:model "dashboard"
:id 3
:name "Main Dashboard"
@@ -181,9 +164,9 @@
:collection {:name "Finance" :authority_level "official"}
:updated_at "2024-01-03"
:created_at "2024-01-03"}]
(is (= expected (#'search/postprocess-search-result result)))))
(is (= expected (#'search/transform-search-result result)))))
(testing "question (card) result postprocessing with moderated_status"
(testing "question (card) result transformation with moderated_status"
(let [result {:model "card"
:id 4
:name "Q1"
@@ -201,9 +184,9 @@
:collection {:name "Analytics" :authority_level nil}
:updated_at "2024-01-04"
:created_at "2024-01-04"}]
(is (= expected (#'search/postprocess-search-result result)))))
(is (= expected (#'search/transform-search-result result)))))
(testing "metric result postprocessing"
(testing "metric result transformation"
(let [result {:model "metric"
:id 5
:name "Revenue"
@@ -220,9 +203,9 @@
:collection {}
:updated_at "2024-01-05"
:created_at "2024-01-05"}]
(is (= expected (#'search/postprocess-search-result result)))))
(is (= expected (#'search/transform-search-result result)))))
(testing "database result postprocessing"
(testing "database result transformation"
(let [result {:model "database"
:id 6
:name "Production DB"
@@ -235,7 +218,7 @@
:description "Main database"
:updated_at "2024-01-06"
:created_at "2024-01-06"}]
(is (= expected (#'search/postprocess-search-result result))))))
(is (= expected (#'search/transform-search-result result))))))
(deftest search-test
(mt/with-premium-features #{:content-verification}
@@ -257,20 +240,20 @@
perms/sandboxed-user? (fn [] false)
api/*current-user-id* 1]
(testing "search returns postprocessed results for term queries"
(testing "search returns transformed 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/postprocess-search-result order-table)]]
expected [(#'search/transform-search-result order-table)]]
(is (= expected results)))))
(testing "search returns postprocessed results for semantic queries"
(testing "search returns transformed 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/postprocess-search-result dashboard)]]
expected [(#'search/transform-search-result dashboard)]]
(is (= expected results)))))
(testing "search combines term and semantic queries using RRF"

View File

@@ -9,10 +9,7 @@
[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])
(:import [java.time Instant]))
(set! *warn-on-reflection* true)
[metabase.util.json :as json]))
(deftest ^:parallel verified-score-test
(let [score #'ee-scoring/verified-score
@@ -183,31 +180,3 @@
(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,21 +324,3 @@
["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

@@ -1,159 +0,0 @@
(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 { type LegacyRef, useEffect, useState } from "react";
import { 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 as LegacyRef<HTMLTextAreaElement>}
ref={metabot.promptInputRef}
autoFocus
value={metabot.prompt}
disabled={metabot.isDoingScience}

View File

@@ -1,25 +1,16 @@
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 = [
"navigate_to",
"state",
"todo_list",
"transform_suggestion",
];
export const knownDataPartTypes = ["state", "navigate_to"];
export type KnownDataPart =
| { type: "navigate_to"; version: 1; value: string }
| { type: "state"; version: 1; value: Record<string, any> }
| { type: "todo_list"; version: 1; value: MetabotTodoItem[] }
| { type: "transform_suggestion"; version: 1; value: SuggestedTransform };
| { type: "navigate_to"; version: 1; value: string };
export const toolCallPartSchema = Yup.object({
toolCallId: Yup.string().required(),

View File

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

View File

@@ -10,7 +10,8 @@ import {
Text,
UnstyledButton,
} from "metabase/ui";
import type { SuggestionModel } from "metabase-enterprise/rich_text_editing/tiptap/extensions/shared/types";
import type { SuggestionModel } from "../types";
import S from "./MenuItems.module.css";
@@ -28,7 +29,6 @@ export interface MenuItem {
model?: SuggestionModel;
id?: number | string;
href?: string;
hasSubmenu?: boolean;
}
export const MenuItemComponent = ({
@@ -65,10 +65,6 @@ export const MenuItemComponent = ({
</Text>
)}
</Stack>
{item.hasSubmenu && (
<Icon name="chevronright" size=".75rem" color="text-light" />
)}
</Group>
</UnstyledButton>
);

View File

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

View File

@@ -22,7 +22,6 @@ export const ALLOWED_EMBED_SETTING_KEYS_MAP = {
"theme",
"locale",
"preferredAuthMethod",
"fetchRequestToken",
"useExistingUserSession",
] satisfies (keyof SdkIframeEmbedBaseSettings)[],
dashboard: [
@@ -79,5 +78,4 @@ export type AllowedEmbedSettingKey =
export const DISABLE_UPDATE_FOR_KEYS = [
"instanceUrl",
"useExistingUserSession",
"fetchRequestToken",
] as const satisfies AllowedEmbedSettingKey[];

View File

@@ -412,24 +412,7 @@ export abstract class MetabaseEmbedElement extends HTMLElement {
data: Message["data"],
) {
if (this._iframe?.contentWindow) {
const normalizedData = Object.entries(data).reduce(
(acc, [key, value]) => {
// Functions are not serializable, so we ignore them.
if (typeof value === "function") {
return acc;
}
acc[key as keyof typeof acc] = value;
return acc;
},
{} as Message["data"],
);
this._iframe.contentWindow.postMessage(
{ type, data: normalizedData },
"*",
);
this._iframe.contentWindow.postMessage({ type, data }, "*");
}
}
@@ -463,8 +446,7 @@ export abstract class MetabaseEmbedElement extends HTMLElement {
* @returns {{ method: "saml" | "jwt", sessionToken: {jwt: string} }}
*/
private async _getMetabaseSessionToken() {
const { instanceUrl, preferredAuthMethod, fetchRequestToken } =
this.properties;
const { instanceUrl, preferredAuthMethod } = this.properties;
const urlResponseJson = await connectToInstanceAuthSso(instanceUrl, {
headers: this._getAuthRequestHeader(),
@@ -484,7 +466,6 @@ export abstract class MetabaseEmbedElement extends HTMLElement {
responseUrl,
instanceUrl,
this._getAuthRequestHeader(hash),
fetchRequestToken,
);
return { method, sessionToken };

View File

@@ -1,10 +1,7 @@
import type { MetabaseError } from "embedding-sdk-bundle/errors";
import type { MetabaseErrorCode } from "embedding-sdk-bundle/errors/error-code";
import type { MetabaseAuthMethod } from "embedding-sdk-bundle/types";
import type {
MetabaseEmbeddingSessionToken,
MetabaseFetchRequestTokenFn,
} from "embedding-sdk-bundle/types/refresh-token";
import type { MetabaseEmbeddingSessionToken } from "embedding-sdk-bundle/types/refresh-token";
import type {
CollectionBrowserListColumns,
EmbeddingEntityType,
@@ -143,7 +140,6 @@ export type SdkIframeEmbedBaseSettings = {
theme?: MetabaseTheme;
locale?: string;
preferredAuthMethod?: MetabaseAuthMethod;
fetchRequestToken?: MetabaseFetchRequestTokenFn;
/** Whether we should use the existing user session (i.e. admin user's cookie) */
useExistingUserSession?: boolean;
@@ -159,10 +155,7 @@ export type SdkIframeEmbedTemplateSettings =
| BrowserEmbedOptions;
/** Settings used by the sdk embed route */
export type SdkIframeEmbedSettings = Omit<
SdkIframeEmbedBaseSettings,
"fetchRequestToken"
> &
export type SdkIframeEmbedSettings = SdkIframeEmbedBaseSettings &
SdkIframeEmbedTemplateSettings;
export type SdkIframeEmbedElementSettings = SdkIframeEmbedBaseSettings &

View File

@@ -47,25 +47,3 @@
.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,14 +3,12 @@
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;
@@ -30,22 +28,20 @@ const getComponents = ({
node?: any;
[key: string]: any;
}) => {
const parsed = parseMetabaseProtocolLink(node.properties.href);
if (parsed) {
return (
<MarkdownSmartLink
onInternalLinkClick={onInternalLinkClick}
name={String(node.children?.[0]?.value ?? "")}
{...parsed}
/>
);
}
if (href && href.startsWith("/")) {
// A custom handler for internal links (use by Embedding SDK)
if (onInternalLinkClick) {
return (
<a {...rest} onClick={() => onInternalLinkClick(href)}>
{children}
</a>
);
}
if (href?.startsWith("/")) {
return (
<InternalLink onInternalLinkClick={onInternalLinkClick} href={href}>
<Link to={href} variant="brand">
{children}
</InternalLink>
</Link>
);
}

View File

@@ -1,26 +0,0 @@
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

@@ -1,52 +0,0 @@
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,28 +4,17 @@ 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";
// TODO: add test coverage for these
export interface MetabotConfig {
emptyText?: string;
hideSuggestedPrompts?: boolean;
preventClose?: boolean;
preventRetryMessage?: boolean;
suggestionModels: SuggestionModel[];
}
export interface MetabotProps {
hide?: boolean;
config?: MetabotConfig;
}
export const MetabotAuthenticated = ({ hide, config }: MetabotProps) => {
export const MetabotAuthenticated = ({ hide }: MetabotProps) => {
const { visible, setVisible } = useMetabotAgent();
useEffect(() => {
@@ -55,7 +44,7 @@ export const MetabotAuthenticated = ({ hide, config }: MetabotProps) => {
return (
<ErrorBoundary errorComponent={() => null}>
<MetabotChat config={config} />
<MetabotChat />
</ErrorBoundary>
);
};

View File

@@ -1,75 +0,0 @@
.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

@@ -1,289 +0,0 @@
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

@@ -1,32 +0,0 @@
.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

@@ -1,116 +0,0 @@
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.3rem;
padding: 0 0.75rem 0 0.25rem;
display: flex;
align-items: center;
gap: 0.5rem;
@@ -27,7 +27,7 @@
min-height: 3rem;
border: 1px solid var(--mb-color-border);
&:focus-within {
&:has(.textarea textarea:focus) {
border-color: var(--mb-color-brand);
}
}
@@ -36,6 +36,36 @@
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%;
@@ -63,12 +93,6 @@
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;
@@ -80,14 +104,6 @@
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,6 +1,7 @@
import cx from "classnames";
import { useCallback, useMemo } from "react";
import { 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";
@@ -13,39 +14,23 @@ 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";
const defaultConfig: MetabotConfig = {
suggestionModels: [
"dataset",
"metric",
"card",
"table",
"database",
"dashboard",
],
};
export const MetabotChat = ({
config = defaultConfig,
}: {
config?: MetabotConfig;
}) => {
export const MetabotChat = () => {
const metabot = useMetabotAgent();
const { handleSubmitInput, handleRetryMessage, handleResetInput } =
useMetabotChatHandlers(config);
useMetabotChatHandlers();
const hasMessages =
metabot.messages.length > 0 || metabot.errorMessages.length > 0;
@@ -67,15 +52,6 @@ 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}
@@ -88,8 +64,8 @@ export const MetabotChat = ({
<Box ref={headerRef} className={Styles.header}>
<Flex align-items="center">
<Text lh={1} fz="sm" c="text-secondary">
{metabot.profileOverride
? t`Using profile: ${metabot.profileOverride}`
{metabot.profile
? t`Using profile: ${metabot.profile}`
: t`Metabot isn't perfect. Double-check results.`}
</Text>
</Flex>
@@ -103,14 +79,9 @@ export const MetabotChat = ({
<Icon c="text-primary" name="revert" />
</ActionIcon>
</Tooltip>
{!config.preventClose && (
<ActionIcon
onClick={handleClose}
data-testid="metabot-close-chat"
>
<Icon c="text-primary" name="close" />
</ActionIcon>
)}
<ActionIcon onClick={handleClose} data-testid="metabot-close-chat">
<Icon c="text-primary" name="close" />
</ActionIcon>
</Flex>
</Box>
@@ -132,33 +103,33 @@ export const MetabotChat = ({
data-testid="metabot-empty-chat-info"
>
<Box component={EmptyDashboardBot} w="6rem" />
<Text c="text-light" maw="12rem" ta="center">
{config.emptyText ??
t`I can help you explore your metrics and models.`}
</Text>
<Text
c="text-light"
maw="12rem"
ta="center"
>{t`I can help you explore your metrics and models.`}</Text>
</Flex>
{!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>
)}
{/* 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>
</>
)}
@@ -175,12 +146,20 @@ export const MetabotChat = ({
isDoingScience={metabot.isDoingScience}
showFeedbackButtons
/>
{/* loading */}
{metabot.isDoingScience && (
<MetabotThinking toolCalls={metabot.toolCalls} />
<MetabotThinking
toolCalls={metabot.toolCalls}
hasStartedResponse={
_.last(metabot.messages)?.role === "agent"
}
/>
)}
{/* filler - height gets set via ref mutation */}
<div ref={fillerRef} data-testid="metabot-message-filler" />
{/* long convo warning */}
{metabot.isLongConversation && <MetabotResetLongChatButton />}
</Box>
@@ -193,17 +172,41 @@ export const MetabotChat = ({
Styles.inputContainer,
metabot.isDoingScience && Styles.inputContainerLoading,
)}
data-testid="metabot-chat-input"
>
<MetabotChatEditor
<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}
ref={metabot.promptInputRef}
value={metabot.prompt}
autoFocus
disabled={metabot.isDoingScience}
value={metabot.prompt}
className={cx(
Styles.textarea,
metabot.isDoingScience && Styles.textareaLoading,
)}
placeholder={t`Tell me to do something, or ask a question`}
onChange={handleEditorChange}
onSubmit={handleEditorSubmit}
suggestionModels={config.suggestionModels}
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);
}
}}
/>
</Paper>
</Box>

View File

@@ -1,70 +0,0 @@
.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

@@ -1,203 +0,0 @@
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

@@ -1,94 +0,0 @@
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

@@ -1,52 +0,0 @@
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,18 +13,13 @@ 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";
@@ -54,8 +49,7 @@ export const MessageContainer = ({
/>
);
interface UserMessageProps extends Omit<BaseMessageProps, "message"> {
message: MetabotUserChatMessage;
interface UserMessageProps extends BaseMessageProps {
onCopy: () => void;
}
@@ -67,22 +61,14 @@ export const UserMessage = ({
...props
}: UserMessageProps) => (
<MessageContainer chatRole={message.role} {...props}>
{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>
)}
<Text
className={cx(
Styles.message,
message.role === "user" && Styles.messageUser,
)}
>
{message.message}
</Text>
<Flex className={Styles.messageActions}>
{!hideActions && (
<ActionIcon
@@ -118,9 +104,8 @@ const FeedbackButton = ({
</ActionIcon>
);
interface AgentMessageProps extends Omit<BaseMessageProps, "message"> {
message: MetabotAgentChatMessage;
onRetry?: (messageId: string) => void;
interface AgentMessageProps extends BaseMessageProps {
onRetry: (messageId: string) => void;
onCopy: (messageId: string) => void;
showFeedbackButtons: boolean;
setFeedbackMessage?: (data: { messageId: string; positive: boolean }) => void;
@@ -139,77 +124,66 @@ export const AgentMessage = ({
onInternalLinkClick,
hideActions,
...props
}: AgentMessageProps) => {
return (
<MessageContainer chatRole={message.role} {...props}>
{message.type === "text" && (
<AIMarkdown
className={Styles.message}
onInternalLinkClick={onInternalLinkClick}
>
{message.message}
</AIMarkdown>
)}
{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,
})
}
/>
</>
)}
}: AgentMessageProps) => (
<MessageContainer chatRole={message.role} {...props}>
<AIMarkdown
className={Styles.message}
onInternalLinkClick={onInternalLinkClick}
>
{message.message}
</AIMarkdown>
{onRetry && (
<ActionIcon
onClick={() => onRetry(message.id)}
h="sm"
data-testid="metabot-chat-message-retry"
>
<Icon name="revert" size="1rem" />
</ActionIcon>
)}
</>
)}
</Flex>
</MessageContainer>
);
};
<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>
</>
)}
</Flex>
</MessageContainer>
);
export const AgentErrorMessage = ({
message,
@@ -270,7 +244,7 @@ export const Messages = ({
}: {
messages: MetabotChatMessage[];
errorMessages: MetabotErrorMessage[];
onRetryMessage?: (messageId: string) => void;
onRetryMessage: (messageId: string) => void;
isDoingScience: boolean;
showFeedbackButtons: boolean;
onInternalLinkClick?: (navigateToPath: string) => void;
@@ -304,11 +278,7 @@ export const Messages = ({
const onAgentMessageCopy = useCallback(
(messageId: string) => {
const allMessages = getFullAgentReply(messages, messageId);
const textMessages = allMessages.filter(
(msg): msg is MetabotAgentTextChatMessage =>
msg.role === "agent" && msg.type === "text",
);
clipboard.copy(textMessages.map((msg) => msg.message).join("\n\n"));
clipboard.copy(allMessages.map((msg) => msg.message).join("\n\n"));
},
[messages, clipboard],
);
@@ -329,7 +299,7 @@ export const Messages = ({
{messages.map((message, index) =>
message.role === "agent" ? (
<AgentMessage
key={"msg-" + message.id}
key={"msg-" + index}
data-testid="metabot-chat-message"
message={message}
onRetry={onRetryMessage}
@@ -337,24 +307,16 @@ export const Messages = ({
showFeedbackButtons={showFeedbackButtons}
setFeedbackMessage={setFeedbackModal}
submittedFeedback={feedbackState.submitted[message.id]}
hideActions={
isDoingScience || messages[index + 1]?.role === "agent"
}
hideActions={messages[index + 1]?.role === "agent"}
onInternalLinkClick={onInternalLinkClick}
/>
) : (
<UserMessage
key={"msg-" + message.id}
key={"msg-" + index}
data-testid="metabot-chat-message"
message={message}
hideActions={isDoingScience && messages.length === index + 1}
onCopy={() => {
const copyText =
message.type === "action"
? `${message.userMessage}: ${message.message}`
: message.message;
clipboard.copy(copyText);
}}
onCopy={() => clipboard.copy(message.message)}
/>
),
)}

View File

@@ -22,9 +22,9 @@ const ThoughtProcess = ({ toolCalls }: { toolCalls: MetabotToolCall[] }) => {
const slots = new Array(toolCalls.length + 2).fill(null);
return (
<Stack gap={0} mb="sm" className={S.toolCalls}>
<Stack gap={0} className={S.toolCalls}>
{slots.map((_, i) => {
const tc: MetabotToolCall | undefined = toolCalls[i];
const tc = 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,22 +49,30 @@ 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} />
<Loader
color="brand"
type="dots"
size="lg"
data-testid="metabot-response-loader"
/>
{showLoader && (
<Loader
color="brand"
type="dots"
size="lg"
data-testid="metabot-response-loader"
/>
)}
</Stack>
);
};

View File

@@ -48,28 +48,4 @@ 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,7 +7,6 @@ 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";
@@ -27,8 +26,6 @@ export const defaultContext = {
export const MetabotContext = createContext<MetabotCtx>(defaultContext);
export type MetabotPromptInputRef = { focus: () => void };
export const MetabotProvider = ({
children,
}: {
@@ -36,7 +33,7 @@ export const MetabotProvider = ({
}) => {
/* Metabot input */
const [prompt, setPrompt] = useState("");
const promptInputRef = useRef<MetabotChatInputRef>(null);
const promptInputRef = useRef<HTMLTextAreaElement>(null);
/* Metabot context */
const providerFnsRef = useRef<Set<ChatContextProviderFn>>(new Set());

View File

@@ -7,13 +7,12 @@ import { useMetabotContext } from "metabase/metabot";
import { trackMetabotRequestSent } from "../analytics";
import {
type MetabotPromptSubmissionResult,
type MetabotUserChatMessage,
getAgentErrorMessages,
getIsLongMetabotConversation,
getIsProcessing,
getLastAgentMessagesByType,
getMessages,
getMetabotId,
getMetabotReactionsState,
getMetabotRequestId,
getMetabotVisible,
getProfileOverride,
@@ -37,6 +36,9 @@ 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
@@ -66,14 +68,10 @@ export const useMetabotAgent = () => {
[dispatch],
);
const profileOverride = useSelector(getProfileOverride as any) as ReturnType<
const profile = useSelector(getProfileOverride as any) as ReturnType<
typeof getProfileOverride
>;
const reactions = useSelector(getMetabotReactionsState as any) as ReturnType<
typeof getMetabotReactionsState
>;
const resetConversation = useCallback(
() => dispatch(resetConversationAction()),
[dispatch],
@@ -90,27 +88,18 @@ export const useMetabotAgent = () => {
);
const submitInput = useCallback(
async (prompt: string | Omit<MetabotUserChatMessage, "id" | "role">) => {
async (prompt: string) => {
if (!visible) {
setVisible(true);
}
const context = await getChatContext();
const action = await dispatch(
submitInputAction(
typeof prompt === "string"
? {
type: "text",
message: prompt,
context,
metabot_id: metabotRequestId,
}
: {
...prompt,
context,
metabot_id: metabotRequestId,
},
),
submitInputAction({
message: prompt,
context,
metabot_id: metabotRequestId,
}),
);
trackMetabotRequestSent();
@@ -168,6 +157,7 @@ export const useMetabotAgent = () => {
metabotId,
visible,
messages,
lastAgentMessages,
errorMessages,
isLongConversation,
resetConversation,
@@ -176,7 +166,6 @@ export const useMetabotAgent = () => {
submitInput,
retryMessage,
toolCalls,
profileOverride,
reactions,
profile,
};
};

View File

@@ -1,12 +1,8 @@
import { useCallback, useMemo } from "react";
import type { MetabotConfig } from "../components/Metabot";
import { useCallback } from "react";
import { useMetabotAgent } from "./use-metabot-agent";
export const useMetabotChatHandlers = (
config?: Partial<Pick<MetabotConfig, "preventRetryMessage">>,
) => {
export const useMetabotChatHandlers = () => {
const {
isDoingScience,
setPrompt,
@@ -32,12 +28,8 @@ export const useMetabotChatHandlers = (
[isDoingScience, promptInputRef, setPrompt, submitInput],
);
const handleRetryMessage = useMemo(() => {
if (config?.preventRetryMessage) {
return undefined;
}
return (messageId: string) => {
const handleRetryMessage = useCallback(
(messageId: string) => {
if (isDoingScience) {
return;
}
@@ -45,14 +37,9 @@ export const useMetabotChatHandlers = (
setPrompt("");
promptInputRef?.current?.focus();
retryMessage(messageId).catch((err) => console.error(err));
};
}, [
config?.preventRetryMessage,
isDoingScience,
promptInputRef,
retryMessage,
setPrompt,
]);
},
[isDoingScience, promptInputRef, retryMessage, setPrompt],
);
const handleResetInput = useCallback(() => {
setPrompt("");

View File

@@ -986,9 +986,7 @@ describe("metabot-streaming", () => {
// adding messages this long via the ui's input makes the test hang
act(() => {
store.dispatch(
addUserMessage({ id: "1", type: "text", message: longMsg }),
);
store.dispatch(addUserMessage({ id: "1", message: longMsg }));
});
expect(await screen.findByText(/xxxxxxx/)).toBeInTheDocument();
expect(
@@ -996,9 +994,7 @@ describe("metabot-streaming", () => {
).not.toBeInTheDocument();
act(() => {
store.dispatch(
addUserMessage({ id: "2", type: "text", message: longMsg }),
);
store.dispatch(addUserMessage({ id: "2", message: longMsg }));
});
expect(
await screen.findByText(/This chat is getting long/),

View File

@@ -0,0 +1,22 @@
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

@@ -0,0 +1,18 @@
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

@@ -0,0 +1,13 @@
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

@@ -0,0 +1,21 @@
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

@@ -0,0 +1,8 @@
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,19 +16,14 @@ import type {
MetabotAgentRequest,
MetabotAgentResponse,
MetabotChatContext,
MetabotTransformInfo,
MetabotReaction,
} from "metabase-types/api";
import type { Dispatch } from "metabase-types/store";
import { METABOT_ERR_MSG } from "../constants";
import { notifyUnknownReaction, reactionHandlers } from "../reactions";
import {
type MetabotAgentEditSuggestionChatMessage,
type MetabotAgentTodoListChatMessage,
type MetabotErrorMessage,
type MetabotUserChatMessage,
metabot,
} from "./reducer";
import { type MetabotErrorMessage, metabot } from "./reducer";
import {
getAgentErrorMessages,
getAgentRequestMetadata,
@@ -53,7 +48,6 @@ export const {
toolCallEnd,
setProfileOverride,
setMetabotReqIdOverride,
setSuggestedTransform,
} = metabot.actions;
type PromptErrorOutcome = {
@@ -131,7 +125,8 @@ export type MetabotPromptSubmissionResult =
export const submitInput = createAsyncThunk<
MetabotPromptSubmissionResult,
Omit<MetabotUserChatMessage, "id" | "role"> & {
{
message: string;
context: MetabotChatContext;
metabot_id?: string;
}
@@ -163,7 +158,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, ...data }));
dispatch(addUserMessage({ id: messageId, message: data.message }));
const sendMessageRequestPromise = dispatch(
sendAgentRequest({
@@ -234,21 +229,10 @@ export const sendAgentRequest = createAsyncThunk<
signal,
},
{
onDataPart: function handleDataPart(part) {
onDataPart: (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));
@@ -256,44 +240,14 @@ 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: function handleTextPart(part) {
onTextPart: (part) => {
dispatch(addAgentTextDelta(String(part)));
},
onToolCallPart: function handleToolCallPart(part) {
dispatch(toolCallStart(part));
},
onToolResultPart: function handleToolResultPart(part) {
dispatch(toolCallEnd(part));
},
onError: function handleError(part) {
error = part;
},
onToolCallPart: (part) => dispatch(toolCallStart(part)),
onToolResultPart: (part) => dispatch(toolCallEnd(part)),
onError: (part) => (error = part),
},
);
@@ -355,12 +309,7 @@ export const retryPrompt = createAsyncThunk<
dispatch(metabot.actions.rewindStateToMessageId(messageId));
return await dispatch(
submitInput({
type: "text",
message: prompt.message,
context,
metabot_id,
}),
submitInput({ message: prompt.message, context, metabot_id }),
).unwrap();
},
);
@@ -377,6 +326,37 @@ 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,71 +3,19 @@ import _ from "underscore";
import { logout } from "metabase/auth/actions";
import { uuid } from "metabase/lib/uuid";
import type {
MetabotHistory,
MetabotTodoItem,
MetabotTransformInfo,
SuggestedTransform,
} from "metabase-types/api";
import type { MetabotHistory } from "metabase-types/api";
import { TOOL_CALL_MESSAGES } from "../constants";
import { sendAgentRequest } from "./actions";
import { createMessageId } from "./utils";
export type MetabotUserTextChatMessage = {
export type MetabotChatMessage = {
id: string;
role: "user";
type: "text";
role: "user" | "agent";
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;
@@ -82,7 +30,6 @@ export type MetabotToolCall = {
export type MetabotReactionsState = {
navigateToPath: string | null;
suggestedTransform: SuggestedTransform | undefined;
};
export interface MetabotState {
@@ -111,7 +58,6 @@ export const getMetabotInitialState = (): MetabotState => ({
state: {},
reactions: {
navigateToPath: null,
suggestedTransform: undefined,
},
toolCalls: [],
experimental: {
@@ -126,27 +72,24 @@ export const metabot = createSlice({
reducers: {
addUserMessage: (
state,
action: PayloadAction<Omit<MetabotUserChatMessage, "role">>,
action: PayloadAction<Omit<MetabotChatMessage, "role">>,
) => {
const { id, message, ...rest } = action.payload;
const { id, message } = action.payload;
state.errorMessages = [];
state.messages.push({ id, role: "user", message, ...rest } as any);
state.messages.push({ id, role: "user", message });
state.history.push({ id, role: "user", content: message });
},
addAgentMessage: (
state,
action: PayloadAction<Omit<MetabotAgentChatMessage, "id" | "role">>,
action: PayloadAction<Omit<MetabotChatMessage, "id" | "role">>,
) => {
state.toolCalls = [];
state.messages.push({
id: createMessageId(),
role: "agent",
...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);
message: action.payload.message,
});
},
addAgentErrorMessage: (
state,
@@ -157,18 +100,14 @@ 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" &&
lastMessage.type === "text";
const canAppend = !hasToolCalls && lastMessage?.role === "agent";
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,
});
}
@@ -217,7 +156,6 @@ export const metabot = createSlice({
state.isProcessing = false;
state.toolCalls = [];
state.conversationId = uuid();
state.reactions.suggestedTransform = undefined;
state.experimental.metabotReqIdOverride = undefined;
},
resetConversationId: (state) => {
@@ -229,15 +167,6 @@ 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,8 +2,6 @@ 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,
@@ -11,7 +9,6 @@ import {
METABOT_REQUEST_IDS,
} from "../constants";
import type { MetabotUserChatMessage } from "./reducer";
import type { MetabotStoreState } from "./types";
export const getMetabot = (state: MetabotStoreState) => {
@@ -19,9 +16,8 @@ export const getMetabot = (state: MetabotStoreState) => {
};
export const getMetabotVisible = createSelector(
[getMetabot, getLocation],
(metabot, location) =>
location.pathname.startsWith("/admin/transforms") ? true : metabot.visible,
getMetabot,
(metabot) => metabot.visible,
);
export const getMessages = createSelector(
@@ -48,7 +44,7 @@ export const getAgentErrorMessages = createSelector(
// that exact message will be returned.
export const getUserPromptForMessageId = createSelector(
[getMessages, (_, messageId: string) => messageId],
(messages, messageId): MetabotUserChatMessage | undefined => {
(messages, messageId) => {
const messageIndex = messages.findLastIndex((m) => m.id === messageId);
const message = messages[messageIndex];
if (!message) {
@@ -58,13 +54,23 @@ export const getUserPromptForMessageId = createSelector(
if (message.role === "user") {
return message;
} else {
return messages
.slice(0, messageIndex)
.findLast<MetabotUserChatMessage>((m) => m.role === "user");
return messages.slice(0, messageIndex).findLast((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,
@@ -94,7 +100,7 @@ export const getIsLongMetabotConversation = createSelector(
getMessages,
(messages) => {
const totalMessageLength = messages.reduce((sum, msg) => {
return sum + ("message" in msg ? msg.message.length : 0);
return sum + msg.message.length;
}, 0);
return totalMessageLength >= LONG_CONVO_MSG_LENGTH_THRESHOLD;
},
@@ -122,31 +128,17 @@ 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,
getProfile,
(history, state, profile) => ({
getProfileOverride,
(history, state, profileId) => ({
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,
),
...(profile ? { profile_id: profile } : {}),
...(profileId ? { profile_id: profileId } : {}),
}),
);
@@ -154,12 +146,3 @@ 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,6 +3,7 @@ import { createMockState } from "metabase-types/store/mocks";
import {
type MetabotState,
getLastAgentMessagesByType,
getMetabotInitialState,
getUserPromptForMessageId,
} from "./index";
@@ -22,41 +23,71 @@ 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", type: "text", message: "bleh" },
{ id: "2", role: "agent", type: "text", message: "blah" },
{ id: "1", role: "user", message: "bleh" },
{ id: "2", role: "agent", message: "blah" },
],
});
const message = getUserPromptForMessageId(state as any, "1");
expect(message).toEqual({
id: "1",
role: "user",
type: "text",
message: "bleh",
});
expect(message).toEqual({ id: "1", role: "user", message: "bleh" });
});
it("should return the message with the matching id if id is for an agent message", () => {
const state = setup({
messages: [
{ 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" },
{ 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" },
],
});
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",
type: "text",
message: "bleh bleh",
});
expect(message2).toEqual({ id: "3", role: "user", message: "bleh bleh" });
});
});
});

View File

@@ -1,75 +0,0 @@
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,7 +231,6 @@ export const CommandSuggestion = forwardRef<
onSelectEntity: onSelectLinkEntity,
enabled: true,
searchModels: showLinkSearch ? LINK_SEARCH_MODELS : EMBED_SEARCH_MODELS,
canBrowseAll: true,
});
const {
@@ -398,7 +397,6 @@ 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,7 +23,6 @@ interface MentionSuggestionProps {
range: Range;
query: string;
searchModels?: SuggestionModel[];
canBrowseAll?: boolean;
}
interface SuggestionRef {
@@ -34,15 +33,7 @@ const MentionSuggestionComponent = forwardRef<
SuggestionRef,
MentionSuggestionProps
>(function MentionSuggestionComponent(
{
items: _items,
command,
editor,
range,
query,
searchModels,
canBrowseAll = true,
},
{ items: _items, command, editor, range: _range, query, searchModels },
ref,
) {
const document = useSelector(getCurrentDocument);
@@ -58,7 +49,7 @@ const MentionSuggestionComponent = forwardRef<
model: item.model,
label: item.label,
href: item.href,
document, // only used for analytics tracking purposes
document,
});
},
[command, document],
@@ -70,15 +61,12 @@ const MentionSuggestionComponent = forwardRef<
searchResults,
selectedIndex,
modal,
selectedSearchModelName,
handlers,
} = useEntitySuggestions({
query,
editor,
range,
searchModels,
onSelectEntity,
canBrowseAll,
});
useImperativeHandle(ref, () => ({
@@ -90,10 +78,7 @@ const MentionSuggestionComponent = forwardRef<
}
return (
<SuggestionPaper
aria-label={t`Mention Dialog`}
key={selectedSearchModelName}
>
<SuggestionPaper aria-label={t`Mention Dialog`}>
<EntitySearchSection
menuItems={menuItems}
selectedIndex={selectedIndex}
@@ -105,8 +90,6 @@ const MentionSuggestionComponent = forwardRef<
onModalSelect={handlers.handleModalSelect}
onModalClose={handlers.handleModalClose}
onItemHover={handlers.hoverHandler}
selectedSearchModelName={selectedSearchModelName}
canBrowseAll={canBrowseAll}
/>
</SuggestionPaper>
);
@@ -116,22 +99,15 @@ 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}
canBrowseAll={canBrowseAll}
/>
<MentionSuggestion {...props} ref={ref} searchModels={searchModels} />
);
});
};

View File

@@ -16,7 +16,6 @@ interface MentionProps {
type?: string;
id?: number | string;
model?: string;
label?: string;
}
export const MetabotMentionPluginKey = new PluginKey("metabot-mention");
@@ -54,10 +53,8 @@ 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,11 +27,8 @@ 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,
@@ -41,14 +38,12 @@ 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";
@@ -57,7 +52,6 @@ export type SmartLinkEntity =
| Dashboard
| Collection
| Table
| Transform
| Database
| Document
| MentionableUser;
@@ -228,24 +222,6 @@ 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
},
}),
@@ -253,7 +229,7 @@ export const SmartLink = Node.create<{
},
});
export const useEntityData = (
const useEntityData = (
entityId: number | null,
model: SuggestionModel | null,
) => {
@@ -359,27 +335,17 @@ export const useEntityData = (
export const SmartLinkComponent = memo(
({ node }: NodeViewProps) => {
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 { entityId, model } = node.attrs;
const { entity, isLoading, error } = useEntityData(entityId, model);
const dispatch = useDispatch();
useEffect(() => {
if (entity) {
const name =
"display_name" in entity ? entity.display_name : entity?.name;
dispatch(updateMentionsCache({ entityId, model, name }));
if (entity?.name) {
dispatch(updateMentionsCache({ entityId, model, name: entity.name }));
}
}, [dispatch, entity, entityId, model]);
}, [dispatch, entity?.name, entityId, model]);
const showLoading = isLoading && !entity;
if (showLoading) {
if (isLoading) {
return (
<NodeViewWrapper as="span">
<span className={styles.smartLink}>
@@ -416,15 +382,7 @@ export const SmartLinkComponent = memo(
const entityUrlableModel = entityToUrlableModel(entity, model);
const entityUrl = modelToUrl(entityUrlableModel);
const iconData =
entity === cachedEntity
? getIcon(cachedEntity)
: getIcon(
entityToObjectWithModel(
entity as NonNullable<typeof networkEntity>,
model,
),
);
const iconData = getIcon(entityToObjectWithModel(entity, model));
return (
<NodeViewWrapper as="span">
@@ -470,7 +428,7 @@ function entityToObjectWithModel(
};
}
function getName(entity: { name?: string; display_name?: string }) {
function getName(entity: SmartLinkEntity) {
if ("display_name" in entity && entity.display_name !== "") {
return entity.display_name;
}

View File

@@ -9,6 +9,7 @@ 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,
@@ -18,8 +19,6 @@ 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,7 +11,6 @@ import {
import type { SearchResult } from "metabase-types/api";
interface EntitySearchSectionProps {
canBrowseAll: boolean;
menuItems: MenuItem[];
selectedIndex: number;
onItemSelect: (index: number) => void;
@@ -22,7 +21,6 @@ interface EntitySearchSectionProps {
onModalSelect: (item: QuestionPickerValueItem) => void;
onModalClose: () => void;
onItemHover: (index: number) => void;
selectedSearchModelName?: string;
}
export function EntitySearchSection({
@@ -36,18 +34,9 @@ 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}
@@ -57,31 +46,20 @@ export function EntitySearchSection({
onMouseEnter={() => onItemHover(index)}
/>
))}
{query.length > 0 &&
menuItems.length === 0 &&
searchResults.length === 0 ? (
{query.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)}
/>
{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}
/>
)}
</>
{modal === "question-picker" && (
<QuestionPickerModal onChange={onModalSelect} onClose={onModalClose} />
)}
</>
);

View File

@@ -1,4 +1,4 @@
import type { SuggestionModel } from "./types";
import type { SuggestionModel } from "metabase-enterprise/documents/components/Editor/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,8 +11,6 @@ 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);
@@ -82,21 +80,6 @@ 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

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

View File

@@ -3,6 +3,7 @@ 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,
@@ -10,8 +11,6 @@ import type {
SearchResult,
} from "metabase-types/api";
import type { SuggestionModel } from "../shared/types";
import {
LINK_SEARCH_LIMIT,
LINK_SEARCH_MODELS,
@@ -30,7 +29,6 @@ interface UseEntitySearchOptions {
onSelectSearchResult: (item: SearchResult) => void;
onSelectUser: (item: MentionableUser) => void;
enabled?: boolean;
shouldFetchRecents?: boolean;
searchModels?: SuggestionModel[];
}
@@ -46,9 +44,10 @@ 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
@@ -74,7 +73,7 @@ export function useEntitySearch({
limit: LINK_SEARCH_LIMIT,
},
{
skip: !enabled || shouldFetchRecents,
skip: !enabled || !query || query.length === 0,
},
);
@@ -99,7 +98,7 @@ export function useEntitySearch({
const menuItems = useMemo(() => {
const items: MenuItem[] = [];
if (!shouldFetchRecents) {
if (query.length > 0) {
if (!isUsersLoading) {
items.push(...buildUserMenuItems(users, onSelectUser));
}
@@ -117,7 +116,7 @@ export function useEntitySearch({
return items;
}, [
shouldFetchRecents,
query,
searchResults,
users,
isSearchLoading,
@@ -129,9 +128,13 @@ export function useEntitySearch({
onSelectUser,
]);
const isLoading =
(shouldFetchRecents && isRecentsLoading) ||
(query.length > 0 && isSearchLoading);
return {
menuItems,
isLoading: shouldFetchRecents ? isRecentsLoading : isSearchLoading,
isLoading,
searchResults,
};
}

View File

@@ -1,26 +1,21 @@
import type { Editor, Range } from "@tiptap/core";
import { useCallback, useEffect, useMemo, useState } from "react";
import type { Editor } from "@tiptap/core";
import { useCallback, useEffect, useState } from "react";
import type { QuestionPickerValueItem } from "metabase/common/components/Pickers/QuestionPicker";
import { getTranslatedEntityName } from "metabase/common/utils/model-names";
import type { QuestionPickerValueItem } from "metabase/common/components/Pickers/QuestionPicker/types";
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 {
buildSearchModelMenuItems,
entityToUrlableModel,
} from "./suggestionUtils";
import type { SuggestionModel } from "./types";
import { entityToUrlableModel } from "./suggestionUtils";
import { useEntitySearch } from "./useEntitySearch";
interface UseEntitySuggestionsOptions {
query: string;
editor: Editor;
range?: Range;
onSelectEntity: (item: {
id: number | string;
model: string;
@@ -29,7 +24,6 @@ interface UseEntitySuggestionsOptions {
}) => void;
enabled?: boolean;
searchModels?: SuggestionModel[];
canBrowseAll: boolean;
}
interface UseEntitySuggestionsResult {
@@ -39,7 +33,6 @@ interface UseEntitySuggestionsResult {
selectedIndex: number;
modal: "question-picker" | null;
totalItems: number;
selectedSearchModelName?: string;
handlers: {
selectItem: (index: number) => void;
upHandler: () => void;
@@ -56,16 +49,12 @@ 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) => {
@@ -103,76 +92,16 @@ export function useEntitySuggestions({
[onSelectEntity],
);
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({
const { menuItems, isLoading, searchResults } = useEntitySearch({
query,
onSelectRecent: handleRecentSelect,
onSelectSearchResult: handleSearchResultSelect,
onSelectUser: handleUserSelect,
enabled: enabled && !isInModelSelectionMode,
shouldFetchRecents:
enabled &&
query.length === 0 &&
!isInModelSelectionMode &&
!selectedSearchModel,
searchModels: effectiveSearchModels,
enabled,
searchModels,
});
// 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 totalItems = menuItems.length + 1;
const selectItem = useCallback(
(index: number) => {
@@ -253,19 +182,13 @@ 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: isInModelSelectionMode ? false : isLoading,
isLoading,
searchResults,
selectedIndex,
modal,
totalItems,
selectedSearchModelName,
handlers: {
selectItem,
upHandler,

View File

@@ -1,5 +1,4 @@
import {
type Placement,
type VirtualElement,
autoUpdate,
computePosition,
@@ -12,33 +11,11 @@ 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;
@@ -65,11 +42,11 @@ export const createSuggestionRenderer = <I = unknown, TSelected = unknown>(
};
computePosition(virtualElement, element, {
placement: config.positionOptions.placement,
placement: "bottom-start",
strategy: "fixed",
middleware: [
flip({
fallbackPlacements: config.positionOptions.fallbackPlacements,
fallbackPlacements: ["top-start", "bottom-end", "top-end"],
padding: 4,
}),
shift({

View File

@@ -7,7 +7,6 @@ import { completion } from "./utils";
export function PythonEditor({
value,
proposedValue,
onChange,
withPandasCompletions,
className,
@@ -15,8 +14,6 @@ export function PythonEditor({
...rest
}: {
value: string;
proposedValue?: string;
onChange?: (value: string) => void;
withPandasCompletions?: boolean;
className?: string;
@@ -26,7 +23,6 @@ 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, Button, Checkbox, Flex, Icon, Stack, Tooltip } from "metabase/ui";
import { Box, Checkbox, Flex, Icon, Stack } from "metabase/ui";
import { SHARED_LIB_IMPORT_PATH } from "metabase-enterprise/transforms-python/constants";
import { getPythonLibraryUrl } from "metabase-enterprise/transforms-python/urls";
@@ -16,7 +16,6 @@ import { hasImport, insertImport, removeImport } from "./utils";
type PythonEditorBodyProps = {
source: string;
proposedSource?: string;
isRunnable: boolean;
onChange: (source: string) => void;
onRun?: () => void;
@@ -25,15 +24,12 @@ 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,
@@ -41,60 +37,29 @@ 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"
/>
<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>
{withDebugger && (
<Box p="md">
<RunButtonWithTooltip
disabled={!isRunnable}
isRunning={isRunning}
isDirty={isDirty}
onRun={onRun}
onCancel={onCancel}
getTooltip={() => t`Run Python script`}
/>
</Box>
)}
<SharedLibraryActions source={source} onChange={onChange} />
</Flex>
</MaybeResizableBox>

View File

@@ -8,7 +8,6 @@ import type {
PythonTransformSource,
PythonTransformTableAliases,
Table,
Transform,
} from "metabase-types/api";
import { PythonDataPicker } from "./PythonDataPicker";
@@ -21,7 +20,6 @@ import {
useShouldShowPythonDebugger,
useTestPythonTransform,
} from "./utils";
import { useRegisterMetabotTransformContext } from "metabase-enterprise/transforms/hooks/use-register-transform-metabot-context";
export type PythonTransformSourceDraft = {
type: "python";
@@ -31,37 +29,25 @@ 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);
@@ -72,7 +58,6 @@ export function PythonTransformEditor({
};
setSource(newSource);
setIsSourceDirty(true);
onChange?.(newSource);
};
const handleDataChange = (
@@ -102,14 +87,6 @@ export function PythonTransformEditor({
}
};
const handleAcceptProposed =
proposedSource && onAcceptProposed
? () => {
handleScriptChange(proposedSource.body);
onAcceptProposed(proposedSource);
}
: undefined;
const showDebugger = useShouldShowPythonDebugger();
const handleCmdEnter = () => {
@@ -157,11 +134,8 @@ 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,7 +16,6 @@ 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";
@@ -57,9 +56,6 @@ type EditorBodyProps = {
nativeEditorSelectedText?: string | null;
databases: ApiDatabase[];
proposedQuestion?: Question;
onRejectProposed?: () => void;
onAcceptProposed?: (query: DatasetQuery) => void;
};
export function EditorBody({
@@ -81,9 +77,6 @@ export function EditorBody({
onChangeNativeEditorSelection,
nativeEditorSelectedText,
onOpenModal,
proposedQuestion,
onRejectProposed,
onAcceptProposed,
}: EditorBodyProps) {
const [isResizing, setIsResizing] = useState(false);
const reportTimezone = useSetting("report-timezone-long");
@@ -147,9 +140,6 @@ export function EditorBody({
setNativeEditorSelectedRange={onChangeNativeEditorSelection}
nativeEditorSelectedText={nativeEditorSelectedText}
onOpenModal={onOpenModal}
proposedQuestion={proposedQuestion}
onRejectProposed={onRejectProposed}
onAcceptProposed={onAcceptProposed}
/>
) : (
<ResizableBox

View File

@@ -8,7 +8,6 @@ import type { QueryValidationResult } from "../types";
type EditorHeaderProps = {
validationResult: QueryValidationResult;
name?: string;
isNew: boolean;
isQueryDirty: boolean;
isSaving: boolean;
@@ -18,7 +17,6 @@ type EditorHeaderProps = {
export function EditorHeader({
validationResult,
name,
isNew,
isQueryDirty,
isSaving,
@@ -30,7 +28,7 @@ export function EditorHeader({
return (
<EditBar
title={getTitle(isNew, name)}
title={getTitle(isNew)}
admin
buttons={[
<Button key="cancel" small onClick={onCancel}>{t`Cancel`}</Button>,
@@ -48,11 +46,11 @@ export function EditorHeader({
);
}
function getTitle(isNew: boolean, name?: string) {
function getTitle(isNew: boolean) {
if (isNew) {
return t`You’re creating a new transform`;
} else {
return t`You’re editing the "${name}" transform`;
return t`You’re editing a transform`;
}
}

View File

@@ -1,5 +1,5 @@
import { useDisclosure, useHotkeys, useToggle } from "@mantine/hooks";
import { useMemo, useState } from "react";
import { useState } from "react";
import { t } from "ttag";
import { useListDatabasesQuery } from "metabase/api";
@@ -12,7 +12,6 @@ import type Question from "metabase-lib/v1/Question";
import type {
NativeQuerySnippet,
QueryTransformSource,
Transform,
} from "metabase-types/api";
import { useQueryMetadata } from "../../hooks/use-query-metadata";
@@ -34,35 +33,25 @@ 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, proposedQuestion, isQueryDirty, setQuestion } =
useQueryState(initialSource.query, proposedSource?.query);
const { question, isQueryDirty, setQuestion } = useQueryState(
initialSource.query,
);
const { isInitiallyLoaded } = useQueryMetadata(question);
const {
result,
@@ -72,21 +61,14 @@ export function QueryEditor({
isResultDirty,
runQuery,
cancelQuery,
} = useQueryResults(question, proposedQuestion);
} = useQueryResults(question);
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 = () => {
@@ -165,7 +147,6 @@ export function QueryEditor({
>
<EditorHeader
validationResult={validationResult}
name={transform?.name}
isNew={isNew}
isSaving={isSaving}
isQueryDirty={isQueryDirty}
@@ -176,7 +157,6 @@ export function QueryEditor({
<Stack flex="2 1 100%" pos="relative">
<EditorBody
question={question}
proposedQuestion={proposedQuestion}
isNative={isNative}
isRunnable={isRunnable}
isRunning={isRunning}
@@ -186,12 +166,6 @@ export function QueryEditor({
onChange={handleChange}
onRunQuery={runQuery}
onCancelQuery={cancelQuery}
onRejectProposed={onRejectProposed}
onAcceptProposed={
proposedSource
? () => onAcceptProposed?.(proposedSource)
: undefined
}
databases={databases?.data ?? []}
onToggleDataReference={handleToggleDataReference}
onToggleSnippetSidebar={handleToggleSnippetSidebar}
@@ -212,6 +186,7 @@ export function QueryEditor({
onRunQuery={runQuery}
onCancelQuery={() => undefined}
/>
{!isNative && (
<NativeQuerySidebarToggle
isShowingNativeQueryPreview={isShowingNativeQueryPreview}

View File

@@ -8,18 +8,13 @@ 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,
proposedQuestion?: Question,
) {
export function useQueryResults(question: 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(currentQuestion),
[currentQuestion],
() => runQuestionQuery(question),
[question],
);
const { result, rawSeries, isRunnable, isResultDirty } = useMemo(() => {
@@ -27,7 +22,7 @@ export function useQueryResults(
? Question.create({
dataset_query: lastRunQuery,
metadata,
visualization_settings: currentQuestion.settings(),
visualization_settings: question.settings(),
})
: null;
const result = results ? results[0] : null;
@@ -35,13 +30,9 @@ export function useQueryResults(
lastRunQuestion && result
? [{ card: lastRunQuestion.card(), data: result.data }]
: null;
const isRunnable = Lib.canRun(
currentQuestion.query(),
currentQuestion.type(),
);
const isRunnable = Lib.canRun(question.query(), question.type());
const isResultDirty =
lastRunQuestion == null ||
currentQuestion.isDirtyComparedTo(lastRunQuestion);
lastRunQuestion == null || question.isDirtyComparedTo(lastRunQuestion);
return {
result,
@@ -49,12 +40,12 @@ export function useQueryResults(
isRunnable,
isResultDirty,
};
}, [currentQuestion, results, metadata, lastRunQuery]);
}, [question, results, metadata, lastRunQuery]);
const handleRunQuery = useCallback(async () => {
await runQuery();
setLastRunQuery(currentQuestion.datasetQuery());
}, [currentQuestion, runQuery]);
setLastRunQuery(question.datasetQuery());
}, [question, runQuery]);
const handleCancelQuery = useCallback(() => {
return null;

View File

@@ -10,10 +10,7 @@ const DEFAULT_VIZ_SETTINGS: VisualizationSettings = {
"table.pivot": false,
};
export function useQueryState(
initialQuery: DatasetQuery,
proposedQuery?: DatasetQuery,
) {
export function useQueryState(initialQuery: DatasetQuery) {
const [query, setQuery] = useState(initialQuery);
const metadata = useSelector(getMetadata);
@@ -27,30 +24,6 @@ export function useQueryState(
[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],
@@ -60,5 +33,5 @@ export function useQueryState(
setQuery(newQuestion.datasetQuery());
};
return { question, proposedQuestion, isQueryDirty, setQuestion };
return { question, isQueryDirty, setQuestion };
}

View File

@@ -1,18 +0,0 @@
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,25 +28,14 @@ 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) {
@@ -55,7 +44,6 @@ export function CreateTransformModal({
<FocusTrap.InitialFocus />
<CreateTransformForm
source={source}
initValues={initValues}
onCreate={onCreate}
onClose={onClose}
/>
@@ -65,14 +53,26 @@ 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, initValues),
[schemas, initValues],
() => getInitialValues(schemas),
[schemas],
);
if (isLoading || error != null) {
@@ -165,16 +165,12 @@ function CreateTransformForm({
);
}
function getInitialValues(
schemas: string[],
initValues?: Partial<NewTransformValues>,
): NewTransformValues {
function getInitialValues(schemas: string[]): NewTransformValues {
return {
name: "",
description: null,
targetName: "",
targetSchema: schemas?.[0] || null,
...initValues,
};
}
@@ -190,7 +186,7 @@ function getCreateRequest(
target: {
type: "table",
name: targetName,
schema: targetSchema ?? null,
schema: targetSchema,
database: databaseId,
},
};

View File

@@ -1,24 +1,15 @@
import { useDisclosure } from "@mantine/hooks";
import { useMemo, useState } from "react";
import { 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, useSelector } from "metabase/lib/redux";
import { useDispatch } 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";
@@ -28,8 +19,8 @@ import { getTransformListUrl, getTransformUrl } from "../../urls";
import { CreateTransformModal } from "./CreateTransformModal";
import {
type InitialTransformSource,
getInitialTransformSource,
getInitialPythonTransformSource,
getInitialQueryTransformSource,
} from "./utils";
type NewTransformPageParams = {
@@ -49,68 +40,7 @@ type NewTransformPageProps = {
export function NewTransformPage({ params }: NewTransformPageProps) {
const { type, cardId } = getParsedParams(params);
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 [source, setSource] = useState<TransformSource | null>(null);
const [isModalOpened, { open: openModal, close: closeModal }] =
useDisclosure();
const dispatch = useDispatch();
@@ -124,44 +54,21 @@ export function NewTransformPageInner({
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
initialSource={initialSource}
proposedSource={proposedSource}
onChange={handleChange}
type={type}
cardId={cardId}
onSave={handleSave}
onCancel={handleCancel}
onRejectProposed={handleRejectProposed}
onAcceptProposed={handleSave}
/>
{isModalOpened && source && (
{isModalOpened && source !== null && (
<CreateTransformModal
source={source}
initValues={createTransformInitValues}
onCreate={handleCreate}
onClose={closeModal}
/>
@@ -170,54 +77,54 @@ export function NewTransformPageInner({
);
}
interface NewTransformEditorBody {
initialSource: InitialTransformSource;
proposedSource?: TransformSource;
onChange: (source: DraftTransform["source"]) => void;
function NewTransformEditorBody(props: {
type: DatasetQuery["type"] | "python";
cardId?: CardId;
onSave: (source: TransformSource) => void;
onCancel: () => void;
onRejectProposed?: () => void;
onAcceptProposed?: (query: TransformSource) => void;
}
function NewTransformEditorBody({
initialSource,
proposedSource,
onChange,
onSave,
onCancel,
onRejectProposed,
onAcceptProposed,
}: NewTransformEditorBody) {
if (initialSource.type === "python") {
}) {
const { type, ...rest } = props;
if (type === "python") {
return (
<PLUGIN_TRANSFORMS_PYTHON.TransformEditor
initialSource={initialSource}
proposedSource={
proposedSource?.type === "python" ? proposedSource : undefined
}
initialSource={getInitialPythonTransformSource()}
isNew
onSave={onSave}
onCancel={onCancel}
onChange={onChange}
onRejectProposed={onRejectProposed}
onAcceptProposed={onAcceptProposed}
onSave={props.onSave}
onCancel={props.onCancel}
/>
);
}
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={initialSource}
proposedSource={
proposedSource?.type === "query" ? proposedSource : undefined
}
initialSource={getInitialQueryTransformSource(card, type)}
isNew
onSave={onSave}
onCancel={onCancel}
onChange={onChange}
onRejectProposed={onRejectProposed}
onAcceptProposed={onAcceptProposed}
/>
);
}

View File

@@ -1,30 +1,10 @@
import type { PythonTransformSourceDraft } from "metabase-enterprise/transforms-python/components/PythonTransformEditor";
import Question from "metabase-lib/v1/Question";
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);
}
import type { Card, DatasetQuery } from "metabase-types/api";
export function getInitialQueryTransformSource(
card: Card | undefined,
type: DatasetQuery["type"] | undefined,
): QueryTransformSource {
) {
const query =
card != null
? card.dataset_query
@@ -33,7 +13,7 @@ export function getInitialQueryTransformSource(
return { type: "query" as const, query };
}
export function getInitialPythonTransformSource(): PythonTransformSourceDraft {
export function getInitialPythonTransformSource() {
return {
type: "python" as const,
"source-database": undefined,

View File

@@ -4,7 +4,6 @@ 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";
@@ -51,12 +50,6 @@ 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,11 +1,9 @@
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, useSelector } from "metabase/lib/redux";
import { useDispatch } from "metabase/lib/redux";
import * as Urls from "metabase/lib/urls";
import { useMetadataToasts } from "metabase/metadata/hooks";
import { PLUGIN_TRANSFORMS_PYTHON } from "metabase/plugins";
@@ -13,11 +11,6 @@ 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";
@@ -61,45 +54,7 @@ export function TransformQueryPageBody({
const dispatch = useDispatch();
const { sendErrorToast, sendSuccessToast } = useMetadataToasts();
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 handleSourceSave = async (source: TransformSource) => {
const { error } = await updateTransform({
id: transform.id,
source,
@@ -109,9 +64,7 @@ export function TransformQueryPageBody({
sendErrorToast(t`Failed to update transform query`);
} else {
sendSuccessToast(t`Transform query updated`);
if (leaveEditor) {
dispatch(push(getTransformUrl(transform.id)));
}
dispatch(push(getTransformUrl(transform.id)));
}
};
@@ -121,41 +74,22 @@ export function TransformQueryPageBody({
if (transform.source.type === "python") {
return (
<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
<PLUGIN_TRANSFORMS_PYTHON.TransformEditor
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}
/>
</AdminSettingsLayout>
);
}
return (
<QueryEditor
initialSource={transform.source}
isNew={false}
isSaving={isLoading}
onSave={handleSourceSave}
onCancel={handleCancel}
/>
);
}

View File

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

View File

@@ -4,11 +4,8 @@ import type {
CardType,
DashboardId,
DatasetQuery,
DraftTransform,
PaginationResponse,
RowValue,
SuggestedTransform,
Transform,
UnsavedCard,
Version,
} from ".";
@@ -58,6 +55,18 @@ 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"
@@ -124,17 +133,11 @@ 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
| MetabotTransformInfo;
| MetabotDocumentInfo;
/* Metabot v3 - API Request Types */
@@ -149,6 +152,7 @@ export type MetabotAgentRequest = {
};
export type MetabotAgentResponse = {
reactions: MetabotReaction[];
history: MetabotHistory[];
conversation_id: string;
state: any;
@@ -240,12 +244,3 @@ 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";
};

Some files were not shown because too many files have changed in this diff Show More