@dbx-tools/appkit-mastra 0.3.20 → 0.3.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -251,6 +251,25 @@ Agents can return `[chart:<id>]` and `[data:<statement_id>]` markers in prose.
251
251
  The embed route resolves them later, which avoids forcing the language model to
252
252
  inline large tables or wait for chart planning before continuing its answer.
253
253
 
254
+ ### Brand The Charts
255
+
256
+ Pass a `brand` to the plugin to theme every generated chart with your brand's
257
+ palette and font; omit it for the default Echarts look.
258
+
259
+ ```ts
260
+ import { brand } from "@dbx-tools/shared-core";
261
+
262
+ plugin.mastra({ agents, storage: true, brand: brand.defaultBrandContext });
263
+ ```
264
+
265
+ `brand` is the portable `BrandContext` shared across the UI, email, and
266
+ libraries, so charts, email, and the chat UI theme from one source. The chart
267
+ planner derives an Echarts theme from it: a series color cycle seeded from
268
+ `colors.primary` / `colors.accent` (plus a colorblind-friendly spread so
269
+ many-series charts stay legible) and a base text style from `typography.sans` /
270
+ `colors.foreground`. Charts render to canvas, so this is applied server-side on
271
+ the Echarts option rather than through the browser `[data-brand]` CSS bridge.
272
+
254
273
  ## Model Selection
255
274
 
256
275
  `model.buildModel()` adapts the generic resolver from
package/package.json CHANGED
@@ -28,22 +28,22 @@
28
28
  "express": "^5.1.0",
29
29
  "pg": "^8.22.0",
30
30
  "zod": "^4.3.6",
31
- "@dbx-tools/appkit": "0.3.20",
32
- "@dbx-tools/core": "0.3.20",
33
- "@dbx-tools/databricks": "0.3.20",
34
- "@dbx-tools/model": "0.3.20",
35
- "@dbx-tools/shared-core": "0.3.20",
36
- "@dbx-tools/genie": "0.3.20",
37
- "@dbx-tools/shared-genie": "0.3.20",
38
- "@dbx-tools/shared-mastra": "0.3.20",
39
- "@dbx-tools/shared-model": "0.3.20"
31
+ "@dbx-tools/appkit": "0.3.22",
32
+ "@dbx-tools/databricks": "0.3.22",
33
+ "@dbx-tools/genie": "0.3.22",
34
+ "@dbx-tools/model": "0.3.22",
35
+ "@dbx-tools/shared-core": "0.3.22",
36
+ "@dbx-tools/shared-mastra": "0.3.22",
37
+ "@dbx-tools/shared-genie": "0.3.22",
38
+ "@dbx-tools/core": "0.3.22",
39
+ "@dbx-tools/shared-model": "0.3.22"
40
40
  },
41
41
  "main": "index.ts",
42
42
  "license": "UNLICENSED",
43
43
  "publishConfig": {
44
44
  "access": "public"
45
45
  },
46
- "version": "0.3.20",
46
+ "version": "0.3.22",
47
47
  "types": "index.ts",
48
48
  "type": "module",
49
49
  "exports": {
package/src/chart.ts CHANGED
@@ -37,7 +37,7 @@ import { z } from "zod";
37
37
  import type { MastraPluginConfig } from "./config";
38
38
  import { buildModel } from "./model";
39
39
  import { model } from "@dbx-tools/shared-model";
40
- import { async, error, hash, log, string } from "@dbx-tools/shared-core";
40
+ import { async, error, hash, log, string, type BrandContext } from "@dbx-tools/shared-core";
41
41
 
42
42
  const logger = log.logger("mastra/chart");
43
43
 
@@ -353,7 +353,7 @@ async function runChartPlanner(
353
353
  ...(abortSignal ? { abortSignal } : {}),
354
354
  });
355
355
  const plan = chartPlanSchema.parse(result.object);
356
- const option = planToEchartsOption(plan, title);
356
+ const option = planToEchartsOption(plan, title, config.brand);
357
357
  return { chartType: plan.chartType, option };
358
358
  }
359
359
 
@@ -551,6 +551,50 @@ export async function fetchChart(
551
551
  }
552
552
  }
553
553
 
554
+ /* ----------------------------- echarts theme ----------------------------- */
555
+
556
+ /**
557
+ * The slice of an Echarts option that carries brand identity: the series
558
+ * color cycle and the base text style (font family + foreground color).
559
+ * Derived from a {@link BrandContext} by {@link brandChartTheme} and merged
560
+ * into every spec by {@link planToEchartsOption}.
561
+ */
562
+ interface ChartTheme {
563
+ color: string[];
564
+ textStyle: { fontFamily: string; color: string };
565
+ }
566
+
567
+ /**
568
+ * Expand two brand hex colors into a legible categorical cycle. Echarts
569
+ * repeats the `color` array across series / categories, so a good default
570
+ * needs several distinct hues, not two. We seed with the brand primary and
571
+ * accent (the identity colors) and follow with a fixed, colorblind-friendly
572
+ * spread so multi-series / many-slice charts stay readable while the first
573
+ * one or two marks still land on-brand.
574
+ */
575
+ function brandColorCycle(primary: string, accent: string): string[] {
576
+ const spread = ["#5B8FF9", "#F6BD16", "#E86452", "#6DC8EC", "#945FB9", "#FF9845", "#1E9493"];
577
+ const seen = new Set<string>();
578
+ return [primary, accent, ...spread].filter((c) => {
579
+ const key = c.toLowerCase();
580
+ if (seen.has(key)) return false;
581
+ seen.add(key);
582
+ return true;
583
+ });
584
+ }
585
+
586
+ /**
587
+ * Derive an Echarts {@link ChartTheme} from a brand context: the primary +
588
+ * accent colors seed the series cycle, the sans stack becomes the base font,
589
+ * and the brand foreground becomes the base text color.
590
+ */
591
+ function brandChartTheme(brand: BrandContext): ChartTheme {
592
+ return {
593
+ color: brandColorCycle(brand.colors.primary, brand.colors.accent),
594
+ textStyle: { fontFamily: brand.typography.sans, color: brand.colors.foreground },
595
+ };
596
+ }
597
+
554
598
  /* ----------------------------- echarts expansion ----------------------------- */
555
599
 
556
600
  /**
@@ -559,10 +603,21 @@ export async function fetchChart(
559
603
  * compact plan shape; tooltip / animation / color / grid defaults
560
604
  * stay consistent across charts and are easy to tune without
561
605
  * retraining model behaviour.
606
+ *
607
+ * When `brand` is set, the resolved spec is themed with the brand's series
608
+ * color cycle and base text style (see {@link brandChartTheme}); otherwise
609
+ * Echarts' defaults apply.
562
610
  */
563
- function planToEchartsOption(plan: ChartPlan, fallbackTitle: string): Record<string, unknown> {
611
+ function planToEchartsOption(
612
+ plan: ChartPlan,
613
+ fallbackTitle: string,
614
+ brand?: BrandContext,
615
+ ): Record<string, unknown> {
564
616
  const baseTitle = plan.title ?? fallbackTitle;
565
617
  const grid = { left: 48, right: 24, top: 56, bottom: 48, containLabel: true };
618
+ const theme = brand ? brandChartTheme(brand) : undefined;
619
+ const themed = (option: Record<string, unknown>): Record<string, unknown> =>
620
+ theme ? { ...theme, ...option } : option;
566
621
 
567
622
  if (plan.chartType === "pie") {
568
623
  // Echarts crashes on null pie slices - filter them out.
@@ -573,7 +628,7 @@ function planToEchartsOption(plan: ChartPlan, fallbackTitle: string): Record<str
573
628
  (d): d is { name: string; value: number } =>
574
629
  d !== null && typeof d === "object" && !Array.isArray(d),
575
630
  );
576
- return {
631
+ return themed({
577
632
  title: { text: baseTitle, left: "center" },
578
633
  tooltip: { trigger: "item" },
579
634
  legend: { bottom: 0 },
@@ -585,14 +640,14 @@ function planToEchartsOption(plan: ChartPlan, fallbackTitle: string): Record<str
585
640
  data: slices,
586
641
  },
587
642
  ],
588
- };
643
+ });
589
644
  }
590
645
 
591
646
  if (plan.chartType === "scatter") {
592
647
  // Echarts crashes on null scatter points - keep only valid
593
648
  // `[x, y]` tuples. Bare numbers / objects / nulls from a
594
649
  // mismatched plan get dropped silently.
595
- return {
650
+ return themed({
596
651
  title: { text: baseTitle, left: "center" },
597
652
  tooltip: { trigger: "item" },
598
653
  legend: { bottom: 0 },
@@ -604,13 +659,13 @@ function planToEchartsOption(plan: ChartPlan, fallbackTitle: string): Record<str
604
659
  type: "scatter",
605
660
  data: s.data.filter((d): d is [number, number] => Array.isArray(d) && d.length === 2),
606
661
  })),
607
- };
662
+ });
608
663
  }
609
664
 
610
665
  // bar / line / area share the same axis layout.
611
666
  const isArea = plan.chartType === "area";
612
667
  const seriesType = plan.chartType === "bar" ? "bar" : "line";
613
- return {
668
+ return themed({
614
669
  title: { text: baseTitle, left: "center" },
615
670
  tooltip: { trigger: "axis" },
616
671
  legend: { bottom: 0 },
@@ -628,7 +683,7 @@ function planToEchartsOption(plan: ChartPlan, fallbackTitle: string): Record<str
628
683
  smooth: seriesType === "line",
629
684
  ...(isArea ? { areaStyle: {} } : {}),
630
685
  })),
631
- };
686
+ });
632
687
  }
633
688
 
634
689
  /* ----------------------------- render_data tool ----------------------------- */
package/src/config.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { BasePluginConfig } from "@databricks/appkit";
11
+ import type { BrandContext } from "@dbx-tools/shared-core";
11
12
  import type { AgentConfig } from "@mastra/core/agent";
12
13
  import { MASTRA_RESOURCE_ID_KEY, MASTRA_THREAD_ID_KEY } from "@mastra/core/request-context";
13
14
  import type { PgVectorConfig, PostgresStoreConfig } from "@mastra/pg";
@@ -442,4 +443,19 @@ export interface MastraPluginConfig extends BasePluginConfig {
442
443
  * surface.
443
444
  */
444
445
  apiAccess?: "scoped" | "full";
446
+ /**
447
+ * Optional brand context applied to charts produced by the built-in
448
+ * `render_data` / `prepare_chart` tools. When set, the chart planner's
449
+ * Echarts output is themed with the brand's palette (series colors derived
450
+ * from `colors.primary` / `colors.accent`) and sans font
451
+ * (`typography.sans`) instead of Echarts' defaults. Omit for the default
452
+ * Echarts look.
453
+ *
454
+ * Pass the portable {@link BrandContext} shared across the UI and
455
+ * libraries (e.g. `brand.defaultBrandContext` from `@dbx-tools/shared-core`,
456
+ * or a customer brand) - the same object the email add-on and the UI
457
+ * `BrandProvider` consume, so a host themes charts, email, and UI from one
458
+ * source.
459
+ */
460
+ brand?: BrandContext;
445
461
  }
package/src/plugin.ts CHANGED
@@ -51,11 +51,9 @@ import {
51
51
  type PluginManifest,
52
52
  type ResourceRequirement,
53
53
  } from "@databricks/appkit";
54
- import type { Agent } from "@mastra/core/agent";
55
- import { Mastra } from "@mastra/core/mastra";
56
- import express from "express";
57
- import type { Pool } from "pg";
58
-
54
+ import { plugin } from "@dbx-tools/appkit";
55
+ import { serving as nodeServing } from "@dbx-tools/model";
56
+ import { error, log, string } from "@dbx-tools/shared-core";
59
57
  import {
60
58
  feedback,
61
59
  routes,
@@ -63,8 +61,12 @@ import {
63
61
  type MastraFeedbackRequest,
64
62
  type StatementData,
65
63
  } from "@dbx-tools/shared-mastra";
66
- import { serving as nodeServing } from "@dbx-tools/model";
67
64
  import { display, type ServingEndpointSummary } from "@dbx-tools/shared-model";
65
+ import type { Agent } from "@mastra/core/agent";
66
+ import { Mastra } from "@mastra/core/mastra";
67
+ import express from "express";
68
+ import type { Pool } from "pg";
69
+
68
70
  import { buildAgents, FALLBACK_AGENT_ID, type BuiltAgents } from "./agents";
69
71
  import { fetchChart } from "./chart";
70
72
  import type { MastraPluginConfig } from "./config";
@@ -78,8 +80,6 @@ import { attachRoutePatchMiddleware, isMastraRequestAllowed, MastraServer } from
78
80
  import { resolveServingConfig } from "./serving";
79
81
  import { fetchStatementData, STATEMENT_ROW_CAP } from "./statement";
80
82
  import { threadsRoute } from "./threads";
81
- import { error, log, string } from "@dbx-tools/shared-core";
82
- import { plugin } from "@dbx-tools/appkit";
83
83
 
84
84
  const GENIE_MANIFEST = plugin.data(genie).plugin.manifest;
85
85
  const LAKEBASE_MANIFEST = plugin.data(lakebase).plugin.manifest;
@@ -330,7 +330,7 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
330
330
  // Reads only in-memory build state, so it's synchronous and needs no OBO
331
331
  // scoping. Registered before the catch-all, same as `/models`.
332
332
  const handleDefaultModel = (req: express.Request, res: express.Response): void => {
333
- const requested = string.firstNonEmpty(req.params["agentId"]);
333
+ const requested = string.firstNonEmpty(req.params.agentId);
334
334
  const agentId = requested ?? this.built?.defaultAgentId ?? FALLBACK_AGENT_ID;
335
335
  const raw = this.built?.defaultModels[agentId];
336
336
  // `"<dynamic>"` (a call-time function) has no fixed id to advertise.
@@ -378,14 +378,14 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
378
378
  // clean 404; thrown errors bubble through `next(err)`.
379
379
  const embedResolvers: Record<string, EmbedResolver> = {
380
380
  chart: (req, id, signal) => {
381
- const timeoutMs = parseTimeoutMs(req.query["timeoutMs"]);
381
+ const timeoutMs = parseTimeoutMs(req.query.timeoutMs);
382
382
  return fetchChart(id, {
383
383
  ...(timeoutMs !== undefined ? { timeoutMs } : {}),
384
384
  signal,
385
385
  });
386
386
  },
387
387
  data: (req, id, signal) => {
388
- const limit = parseStatementLimit(req.query["limit"]);
388
+ const limit = parseStatementLimit(req.query.limit);
389
389
  return this.userScopedSelf(req).fetchStatement(id, {
390
390
  ...(limit !== undefined ? { limit } : {}),
391
391
  signal,
@@ -394,8 +394,8 @@ export class MastraPlugin extends Plugin<MastraPluginConfig> {
394
394
  };
395
395
 
396
396
  router.get(`${routes.MASTRA_ROUTES.embed}/:type/:id`, (req, res, next) => {
397
- const type = req.params["type"] ?? "";
398
- const id = req.params["id"];
397
+ const type = req.params.type ?? "";
398
+ const id = req.params.id;
399
399
  const resolve = embedResolvers[type];
400
400
  if (!resolve) {
401
401
  res.status(404).json({ error: `unsupported embed type: ${type}` });
package/src/server.ts CHANGED
@@ -7,7 +7,9 @@
7
7
  * @module
8
8
  */
9
9
 
10
+ import { randomUUID } from "node:crypto";
10
11
  import { getExecutionContext } from "@databricks/appkit";
12
+ import { http, log, object, string, token } from "@dbx-tools/shared-core";
11
13
  import { feedback, thread } from "@dbx-tools/shared-mastra";
12
14
  import {
13
15
  MASTRA_RESOURCE_ID_KEY,
@@ -17,9 +19,6 @@ import {
17
19
  import { MastraServer as MastraServerExpress } from "@mastra/express";
18
20
  import { trace } from "@opentelemetry/api";
19
21
  import type express from "express";
20
- import { randomUUID } from "node:crypto";
21
-
22
- import { resolveFeedbackEnabled } from "./mlflow";
23
22
 
24
23
  import {
25
24
  MASTRA_REQUEST_ID_KEY,
@@ -30,7 +29,8 @@ import {
30
29
  type MastraPluginConfig,
31
30
  type User,
32
31
  } from "./config";
33
- import { http, log, object, string, token } from "@dbx-tools/shared-core";
32
+ import { resolveFeedbackEnabled } from "./mlflow";
33
+
34
34
  import { extractModelOverride, MASTRA_MODEL_OVERRIDE_KEY, resolveServingConfig } from "./serving";
35
35
  /**
36
36
  * OpenTelemetry's sentinel for "no valid trace" - 32 zero hex chars.
@@ -154,10 +154,7 @@ export class MastraServer extends MastraServerExpress {
154
154
  configureRequestContextScopes(req: express.Request, requestContext: RequestContext) {
155
155
  const scopes = [
156
156
  ...object
157
- .sequence(
158
- token.getAccessTokenScopes(req),
159
- token.getAccessTokenScopes(req, "authorization"),
160
- )
157
+ .sequence(token.getAccessTokenScopes(req), token.getAccessTokenScopes(req, "authorization"))
161
158
  .distinct(),
162
159
  ];
163
160
  requestContext.set(MASTRA_SCOPES_KEY, scopes);
@@ -1,4 +1,5 @@
1
1
  import { string } from "@dbx-tools/shared-core";
2
+ import { type ChatMessage, type ChatRole, openaiChat } from "@dbx-tools/shared-model";
2
3
  /**
3
4
  * Repairs Mastra / AI SDK message replays sent to Databricks Model
4
5
  * Serving before they hit the OpenAI-compatible `/chat/completions`
@@ -6,43 +7,46 @@ import { string } from "@dbx-tools/shared-core";
6
7
  */
7
8
 
8
9
  /**
9
- * OpenAI-flavoured chat message shape we need to mutate. We do not
10
- * import the OpenAI / AI SDK types because both packages keep these
11
- * fields under internal namespaces; the wire payload is the contract
12
- * here and it's stable enough to inline.
10
+ * A chat message as it arrives on the serving wire, plus the extended-thinking
11
+ * fields Databricks-hosted Claude adds. The OpenAI-standard part of the shape
12
+ * is {@link ChatMessage} from `@dbx-tools/shared-model`; only the provider
13
+ * extensions this module strips are declared here.
13
14
  */
14
- export interface ServingChatMessage {
15
- role: "system" | "user" | "assistant" | "tool" | "reasoning";
16
- content?: string | ServingContentPart[];
17
- tool_calls?: Array<{ id: string; type: string; function: unknown }>;
18
- tool_call_id?: string;
15
+ export interface ServingChatMessage extends ChatMessage {
16
+ /** Narrowed to the roles this repair pass reasons about, plus Claude's `reasoning` turn. */
17
+ role: ChatRole | "reasoning";
19
18
  reasoning?: unknown;
20
19
  reasoning_content?: unknown;
21
20
  }
22
21
 
23
- type ServingContentPart = {
24
- type?: string;
25
- text?: string;
26
- [key: string]: unknown;
27
- };
28
-
29
22
  const REASONING_PART_TYPES = new Set(["reasoning", "thinking", "redacted_thinking"]);
30
23
 
31
24
  /**
32
25
  * Parse, sanitize, and re-serialize a `/serving-endpoints/...` POST
33
26
  * body. Returns the original string verbatim when the body is not
34
- * JSON, has no `messages`, or no rewrite was needed.
27
+ * JSON or no rewrite was needed.
35
28
  */
36
29
  export function rewriteServingBody(body: string): string {
37
- let parsed: { messages?: unknown };
30
+ let parsed: Record<string, unknown>;
38
31
  try {
39
- parsed = JSON.parse(body);
32
+ parsed = JSON.parse(body) as Record<string, unknown>;
40
33
  } catch {
41
34
  return body;
42
35
  }
43
- if (!Array.isArray(parsed.messages)) return body;
44
- const messages = parsed.messages as ServingChatMessage[];
45
- const changed = stripReasoningFromServingMessages(messages) || repairAssistantPrefill(messages);
36
+
37
+ // Runs regardless of `messages`: Databricks refuses to parse a body carrying
38
+ // an unknown top-level field, so this failure is not specific to a transcript.
39
+ let changed = openaiChat.stripUnsupportedChatFields(parsed).length > 0;
40
+
41
+ if (Array.isArray(parsed.messages)) {
42
+ const messages = parsed.messages as ServingChatMessage[];
43
+ // Evaluated eagerly, not short-circuited: one transcript can need both
44
+ // reasoning blocks stripped AND a trailing assistant prefill folded back.
45
+ const stripped = stripReasoningFromServingMessages(messages);
46
+ const repaired = repairAssistantPrefill(messages);
47
+ changed = changed || stripped || repaired;
48
+ }
49
+
46
50
  return changed ? JSON.stringify(parsed) : body;
47
51
  }
48
52
 
@@ -145,15 +149,19 @@ export function repairAssistantPrefill(messages: ServingChatMessage[]): boolean
145
149
  return true;
146
150
  }
147
151
 
152
+ /**
153
+ * Flatten content to the prose a human would read: text parts only, separated
154
+ * by blank lines, since the result is spliced into another turn's message body.
155
+ */
148
156
  function textFromServingContent(content: ServingChatMessage["content"]): string {
149
- if (typeof content === "string") return content;
150
- if (!Array.isArray(content)) return "";
151
- return content
152
- .filter((part) => part?.type === "text" && typeof part.text === "string")
153
- .map((part) => part.text as string)
154
- .join("\n\n");
157
+ return openaiChat.chatContentToText(content, { separator: "\n\n", types: ["text"] });
155
158
  }
156
159
 
160
+ /**
161
+ * Whether a turn carries nothing worth replaying. Deliberately stricter than
162
+ * "has no text": a part of any other type (an image, a cache marker) counts as
163
+ * content, so a message holding one is kept even though it has no prose.
164
+ */
157
165
  function isEmptyServingContent(content: ServingChatMessage["content"]): boolean {
158
166
  if (content === undefined) return true;
159
167
  if (typeof content === "string") return content.trim().length === 0;