strapi-plugin-ai-sdk 0.6.7 → 0.6.9

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.
@@ -6,7 +6,7 @@ const reactRouterDom = require("react-router-dom");
6
6
  const designSystem = require("@strapi/design-system");
7
7
  const react = require("react");
8
8
  const styled = require("styled-components");
9
- const index = require("./index-tCUlQhF4.js");
9
+ const index = require("./index-DCEjJ0as.js");
10
10
  const icons = require("@strapi/icons");
11
11
  const Markdown = require("react-markdown");
12
12
  const remarkGfm = require("remark-gfm");
@@ -688,6 +688,21 @@ const ToolCallHeader = styled__default.default.button`
688
688
  background: #dcdce4;
689
689
  }
690
690
  `;
691
+ const Spinner = styled__default.default.span`
692
+ display: inline-block;
693
+ width: 12px;
694
+ height: 12px;
695
+ border: 2px solid #a5a5ba;
696
+ border-top-color: #4945ff;
697
+ border-radius: 50%;
698
+ animation: spin 0.8s linear infinite;
699
+ margin-left: auto;
700
+ flex-shrink: 0;
701
+
702
+ @keyframes spin {
703
+ to { transform: rotate(360deg); }
704
+ }
705
+ `;
691
706
  const ToolCallContent = styled__default.default.pre`
692
707
  margin: 0;
693
708
  padding: 8px 12px;
@@ -739,7 +754,7 @@ function ToolCallDisplay({ toolCall }) {
739
754
  "Tool: ",
740
755
  toolCall.toolName
741
756
  ] }),
742
- toolCall.output !== void 0 && /* @__PURE__ */ jsxRuntime.jsx("span", { style: { marginLeft: "auto", fontWeight: 400, opacity: 0.6 }, children: "completed" })
757
+ toolCall.output === void 0 ? /* @__PURE__ */ jsxRuntime.jsx(Spinner, {}) : /* @__PURE__ */ jsxRuntime.jsx("span", { style: { marginLeft: "auto", fontWeight: 400, opacity: 0.6 }, children: "completed" })
743
758
  ] }),
744
759
  contentLinks.length > 0 && /* @__PURE__ */ jsxRuntime.jsx(ContentLinksRow, { children: contentLinks.map((link) => /* @__PURE__ */ jsxRuntime.jsx(ContentLinkChip, { to: link.to, children: link.label }, link.to)) }),
745
760
  expanded && /* @__PURE__ */ jsxRuntime.jsx(ToolCallContent, { children: toolCall.output === void 0 ? "Waiting for result..." : JSON.stringify(toolCall.output, null, 2) })
@@ -846,6 +861,29 @@ const TypingDots = styled__default.default.span`
846
861
  40% { transform: scale(1); opacity: 1; }
847
862
  }
848
863
  `;
864
+ const ThinkingIndicator = styled__default.default.div`
865
+ display: flex;
866
+ align-items: center;
867
+ gap: 6px;
868
+ margin-top: 8px;
869
+ padding: 6px 0;
870
+ font-size: 12px;
871
+ color: #666687;
872
+ `;
873
+ const ThinkingSpinner = styled__default.default.span`
874
+ display: inline-block;
875
+ width: 14px;
876
+ height: 14px;
877
+ border: 2px solid #dcdce4;
878
+ border-top-color: #4945ff;
879
+ border-radius: 50%;
880
+ animation: spin 0.8s linear infinite;
881
+ flex-shrink: 0;
882
+
883
+ @keyframes spin {
884
+ to { transform: rotate(360deg); }
885
+ }
886
+ `;
849
887
  const EmptyState = styled__default.default.div`
850
888
  display: flex;
851
889
  flex-direction: column;
@@ -884,8 +922,11 @@ const MessageList = react.forwardRef(
884
922
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "beta", textColor: "neutral400", children: "AI Chat" }),
885
923
  /* @__PURE__ */ jsxRuntime.jsx(designSystem.Box, { paddingTop: 2, children: /* @__PURE__ */ jsxRuntime.jsx(designSystem.Typography, { variant: "omega", textColor: "neutral500", children: "Send a message to start the conversation" }) })
886
924
  ] }),
887
- messages.map((message) => {
925
+ messages.map((message, index2) => {
888
926
  const displayContent = message.role === "assistant" && message.content ? autoLinkContentTypeUids(message.content) : message.content;
927
+ const isLastMessage = index2 === messages.length - 1;
928
+ const hasToolsRunning = message.toolCalls?.some((tc) => tc.output === void 0) ?? false;
929
+ const showThinking = isLoading && isLastMessage && message.role === "assistant" && displayContent && hasToolsRunning;
889
930
  return /* @__PURE__ */ jsxRuntime.jsxs(MessageRow, { $isUser: message.role === "user", children: [
890
931
  message.role === "assistant" && /* @__PURE__ */ jsxRuntime.jsx(SparkleIcon, { children: /* @__PURE__ */ jsxRuntime.jsx(icons.Sparkle, {}) }),
891
932
  /* @__PURE__ */ jsxRuntime.jsxs(MessageBubble, { $isUser: message.role === "user", children: [
@@ -897,7 +938,11 @@ const MessageList = react.forwardRef(
897
938
  /* @__PURE__ */ jsxRuntime.jsx("span", {}),
898
939
  /* @__PURE__ */ jsxRuntime.jsx("span", {})
899
940
  ] }),
900
- message.toolCalls?.filter((tc) => !HIDDEN_TOOLS.has(tc.toolName)).map((tc) => /* @__PURE__ */ jsxRuntime.jsx(ToolCallDisplay, { toolCall: tc }, tc.toolCallId))
941
+ message.toolCalls?.filter((tc) => !HIDDEN_TOOLS.has(tc.toolName)).map((tc) => /* @__PURE__ */ jsxRuntime.jsx(ToolCallDisplay, { toolCall: tc }, tc.toolCallId)),
942
+ showThinking && /* @__PURE__ */ jsxRuntime.jsxs(ThinkingIndicator, { children: [
943
+ /* @__PURE__ */ jsxRuntime.jsx(ThinkingSpinner, {}),
944
+ "Working on it…"
945
+ ] })
901
946
  ] })
902
947
  ] }, message.id);
903
948
  }),
@@ -4,7 +4,7 @@ import { Link, useNavigate, Routes, Route } from "react-router-dom";
4
4
  import { Box, Typography, TextInput, Button, Main, SearchForm, Searchbar, Table, Thead, Tr, Th, Tbody, Td, Flex, Pagination, Modal, Field, Textarea, SingleSelect, SingleSelectOption } from "@strapi/design-system";
5
5
  import { useState, useEffect, useCallback, forwardRef, useRef, useMemo } from "react";
6
6
  import styled from "styled-components";
7
- import { P as PLUGIN_ID } from "./index-C6UZ9D-c.mjs";
7
+ import { P as PLUGIN_ID } from "./index-BV9DET_M.mjs";
8
8
  import { Plus, Trash, Sparkle, ArrowLeft, Pencil } from "@strapi/icons";
9
9
  import Markdown from "react-markdown";
10
10
  import remarkGfm from "remark-gfm";
@@ -682,6 +682,21 @@ const ToolCallHeader = styled.button`
682
682
  background: #dcdce4;
683
683
  }
684
684
  `;
685
+ const Spinner = styled.span`
686
+ display: inline-block;
687
+ width: 12px;
688
+ height: 12px;
689
+ border: 2px solid #a5a5ba;
690
+ border-top-color: #4945ff;
691
+ border-radius: 50%;
692
+ animation: spin 0.8s linear infinite;
693
+ margin-left: auto;
694
+ flex-shrink: 0;
695
+
696
+ @keyframes spin {
697
+ to { transform: rotate(360deg); }
698
+ }
699
+ `;
685
700
  const ToolCallContent = styled.pre`
686
701
  margin: 0;
687
702
  padding: 8px 12px;
@@ -733,7 +748,7 @@ function ToolCallDisplay({ toolCall }) {
733
748
  "Tool: ",
734
749
  toolCall.toolName
735
750
  ] }),
736
- toolCall.output !== void 0 && /* @__PURE__ */ jsx("span", { style: { marginLeft: "auto", fontWeight: 400, opacity: 0.6 }, children: "completed" })
751
+ toolCall.output === void 0 ? /* @__PURE__ */ jsx(Spinner, {}) : /* @__PURE__ */ jsx("span", { style: { marginLeft: "auto", fontWeight: 400, opacity: 0.6 }, children: "completed" })
737
752
  ] }),
738
753
  contentLinks.length > 0 && /* @__PURE__ */ jsx(ContentLinksRow, { children: contentLinks.map((link) => /* @__PURE__ */ jsx(ContentLinkChip, { to: link.to, children: link.label }, link.to)) }),
739
754
  expanded && /* @__PURE__ */ jsx(ToolCallContent, { children: toolCall.output === void 0 ? "Waiting for result..." : JSON.stringify(toolCall.output, null, 2) })
@@ -840,6 +855,29 @@ const TypingDots = styled.span`
840
855
  40% { transform: scale(1); opacity: 1; }
841
856
  }
842
857
  `;
858
+ const ThinkingIndicator = styled.div`
859
+ display: flex;
860
+ align-items: center;
861
+ gap: 6px;
862
+ margin-top: 8px;
863
+ padding: 6px 0;
864
+ font-size: 12px;
865
+ color: #666687;
866
+ `;
867
+ const ThinkingSpinner = styled.span`
868
+ display: inline-block;
869
+ width: 14px;
870
+ height: 14px;
871
+ border: 2px solid #dcdce4;
872
+ border-top-color: #4945ff;
873
+ border-radius: 50%;
874
+ animation: spin 0.8s linear infinite;
875
+ flex-shrink: 0;
876
+
877
+ @keyframes spin {
878
+ to { transform: rotate(360deg); }
879
+ }
880
+ `;
843
881
  const EmptyState = styled.div`
844
882
  display: flex;
845
883
  flex-direction: column;
@@ -878,8 +916,11 @@ const MessageList = forwardRef(
878
916
  /* @__PURE__ */ jsx(Typography, { variant: "beta", textColor: "neutral400", children: "AI Chat" }),
879
917
  /* @__PURE__ */ jsx(Box, { paddingTop: 2, children: /* @__PURE__ */ jsx(Typography, { variant: "omega", textColor: "neutral500", children: "Send a message to start the conversation" }) })
880
918
  ] }),
881
- messages.map((message) => {
919
+ messages.map((message, index) => {
882
920
  const displayContent = message.role === "assistant" && message.content ? autoLinkContentTypeUids(message.content) : message.content;
921
+ const isLastMessage = index === messages.length - 1;
922
+ const hasToolsRunning = message.toolCalls?.some((tc) => tc.output === void 0) ?? false;
923
+ const showThinking = isLoading && isLastMessage && message.role === "assistant" && displayContent && hasToolsRunning;
883
924
  return /* @__PURE__ */ jsxs(MessageRow, { $isUser: message.role === "user", children: [
884
925
  message.role === "assistant" && /* @__PURE__ */ jsx(SparkleIcon, { children: /* @__PURE__ */ jsx(Sparkle, {}) }),
885
926
  /* @__PURE__ */ jsxs(MessageBubble, { $isUser: message.role === "user", children: [
@@ -891,7 +932,11 @@ const MessageList = forwardRef(
891
932
  /* @__PURE__ */ jsx("span", {}),
892
933
  /* @__PURE__ */ jsx("span", {})
893
934
  ] }),
894
- message.toolCalls?.filter((tc) => !HIDDEN_TOOLS.has(tc.toolName)).map((tc) => /* @__PURE__ */ jsx(ToolCallDisplay, { toolCall: tc }, tc.toolCallId))
935
+ message.toolCalls?.filter((tc) => !HIDDEN_TOOLS.has(tc.toolName)).map((tc) => /* @__PURE__ */ jsx(ToolCallDisplay, { toolCall: tc }, tc.toolCallId)),
936
+ showThinking && /* @__PURE__ */ jsxs(ThinkingIndicator, { children: [
937
+ /* @__PURE__ */ jsx(ThinkingSpinner, {}),
938
+ "Working on it…"
939
+ ] })
895
940
  ] })
896
941
  ] }, message.id);
897
942
  }),
@@ -36,7 +36,7 @@ const index = {
36
36
  defaultMessage: PLUGIN_ID
37
37
  },
38
38
  Component: async () => {
39
- const { App } = await import("./App-k5doNU8o.mjs");
39
+ const { App } = await import("./App-DKyCb0BY.mjs");
40
40
  return App;
41
41
  }
42
42
  });
@@ -37,7 +37,7 @@ const index = {
37
37
  defaultMessage: PLUGIN_ID
38
38
  },
39
39
  Component: async () => {
40
- const { App } = await Promise.resolve().then(() => require("./App-31DoeBYs.js"));
40
+ const { App } = await Promise.resolve().then(() => require("./App-C_BH5Ir4.js"));
41
41
  return App;
42
42
  }
43
43
  });
@@ -1,3 +1,3 @@
1
1
  "use strict";
2
- const index = require("../_chunks/index-tCUlQhF4.js");
2
+ const index = require("../_chunks/index-DCEjJ0as.js");
3
3
  module.exports = index.index;
@@ -1,4 +1,4 @@
1
- import { i } from "../_chunks/index-C6UZ9D-c.mjs";
1
+ import { i } from "../_chunks/index-BV9DET_M.mjs";
2
2
  export {
3
3
  i as default
4
4
  };
@@ -297,7 +297,7 @@ const searchContentSchema = zod.z.object({
297
297
  'The content type UID to search, e.g. "api::article.article" or "plugin::users-permissions.user"'
298
298
  ),
299
299
  query: zod.z.string().optional().describe("Full-text search query string (searches across all searchable text fields)"),
300
- filters: zod.z.record(zod.z.string(), zod.z.unknown()).optional().describe('Strapi filter object, e.g. { username: { $containsi: "john" } }'),
300
+ filters: zod.z.record(zod.z.string(), zod.z.unknown()).optional().describe('Strapi filter object. Scalar: { title: { $containsi: "hello" } }. Relation: { author: { name: { $eq: "John" } } }. ManyToMany: { contentTags: { title: { $eq: "tutorial" } } }. Operators: $eq, $ne, $containsi, $in, $gt, $lt, $gte, $lte, $null, $notNull.'),
301
301
  fields: zod.z.array(zod.z.string()).optional().describe("Specific fields to return. If omitted, returns all fields (large content fields stripped unless includeContent is true)."),
302
302
  sort: zod.z.string().optional().describe('Sort order, e.g. "createdAt:desc"'),
303
303
  page: zod.z.number().optional().default(1).describe("Page number (starts at 1)"),
@@ -307,7 +307,7 @@ const searchContentSchema = zod.z.object({
307
307
  populate: zod.z.union([zod.z.string(), zod.z.array(zod.z.string()), zod.z.record(zod.z.string(), zod.z.unknown())]).optional().describe('Relations to populate. Defaults to "*" (all). Can be a string, array, or object.'),
308
308
  includeContent: zod.z.boolean().optional().default(false).describe("When true, includes large content fields (content, blocks, body, etc.) in results. Default false to reduce context size.")
309
309
  });
310
- const searchContentDescription = 'Search and query any Strapi content type. Use listContentTypes first to discover available content types and their fields, then use this tool to query specific collections. Use sort (e.g. "createdAt:desc") and pageSize: 1 to get the latest entry. By default, large content fields are stripped from results — set includeContent to true or use fields to get full content.';
310
+ const searchContentDescription = 'Search and query any Strapi content type. Use listContentTypes first to discover available content types and their fields, then use this tool to query specific collections. Use sort (e.g. "createdAt:desc") and pageSize: 1 to get the latest entry. By default, large content fields are stripped from results — set includeContent to true or use fields to get full content. To filter by a relation, nest the filter: { relationField: { fieldOnRelation: { $operator: "value" } } }.';
311
311
  function stripLargeFields(obj) {
312
312
  const stripped = {};
313
313
  for (const [key, value] of Object.entries(obj)) {
@@ -671,24 +671,33 @@ function getDisplayField(strapi, contentType) {
671
671
  }
672
672
  return "id";
673
673
  }
674
+ const MANY_RELATIONS = /* @__PURE__ */ new Set(["oneToMany", "manyToMany"]);
674
675
  function resolveFieldPath(strapi, contentType, fieldPath) {
675
676
  const parts = fieldPath.split(".");
676
677
  const topField = parts[0];
677
678
  const attr = getAttribute(strapi, contentType, topField);
678
679
  if (!attr) {
679
- return { resolvedPath: fieldPath, populate: [] };
680
+ return { resolvedPath: fieldPath, populate: [], isArray: false };
680
681
  }
681
682
  if (attr.type === "relation" && attr.target) {
683
+ const isArray = MANY_RELATIONS.has(attr.relation ?? "");
682
684
  if (parts.length === 1) {
683
685
  const displayField = getDisplayField(strapi, attr.target);
684
686
  return {
685
687
  resolvedPath: `${topField}.${displayField}`,
686
- populate: [topField]
688
+ populate: [topField],
689
+ isArray,
690
+ selectField: displayField
687
691
  };
688
692
  }
689
- return { resolvedPath: fieldPath, populate: [topField] };
693
+ return {
694
+ resolvedPath: fieldPath,
695
+ populate: [topField],
696
+ isArray,
697
+ selectField: parts.slice(1).join(".")
698
+ };
690
699
  }
691
- return { resolvedPath: fieldPath, populate: [] };
700
+ return { resolvedPath: fieldPath, populate: [], isArray: false };
692
701
  }
693
702
  const MAX_PAGINATE = 1e3;
694
703
  const PAGE_SIZE = 100;
@@ -697,7 +706,7 @@ const aggregateContentSchema = zod.z.object({
697
706
  operation: zod.z.enum(["count", "countByField", "countByDateRange"]).describe(
698
707
  "count — total count; countByField — group by a field; countByDateRange — bucket by date"
699
708
  ),
700
- filters: zod.z.record(zod.z.string(), zod.z.unknown()).optional().describe('Strapi filter object, e.g. { category: { name: "tech" } }'),
709
+ filters: zod.z.record(zod.z.string(), zod.z.unknown()).optional().describe('Strapi filter object. Scalar: { title: { $containsi: "hello" } }. Relation: { author: { name: { $eq: "John" } } }. ManyToMany: { contentTags: { title: { $eq: "tutorial" } } }.'),
701
710
  groupByField: zod.z.string().optional().describe('Field to group by for countByField. Just use the field name — relation fields are auto-resolved to their display name (e.g. "author", "category"). You can also use dot paths like "author.email" for a specific sub-field.'),
702
711
  dateField: zod.z.string().optional().default("createdAt").describe('Date field for countByDateRange (default: "createdAt")'),
703
712
  granularity: zod.z.enum(["day", "week", "month"]).optional().default("month").describe("Bucket granularity for countByDateRange"),
@@ -727,17 +736,30 @@ function resolveField(doc, fieldPath) {
727
736
  let current = doc;
728
737
  for (const part of parts) {
729
738
  if (current == null || typeof current !== "object") return void 0;
739
+ if (Array.isArray(current)) return void 0;
730
740
  current = current[part];
731
741
  }
732
742
  return current;
733
743
  }
734
- async function paginateAll(strapi, contentType, baseQuery) {
744
+ function buildPopulate(resolved) {
745
+ if (resolved.populate.length === 0) return "*";
746
+ const populate = {};
747
+ for (const rel of resolved.populate) {
748
+ if (resolved.selectField) {
749
+ populate[rel] = { fields: [resolved.selectField] };
750
+ } else {
751
+ populate[rel] = true;
752
+ }
753
+ }
754
+ return populate;
755
+ }
756
+ async function paginateAll(strapi, contentType, baseQuery, populate = "*") {
735
757
  const docs = [];
736
758
  let page = 1;
737
759
  while (docs.length < MAX_PAGINATE) {
738
760
  const results = await strapi.documents(contentType).findMany({
739
761
  ...baseQuery,
740
- populate: "*",
762
+ populate,
741
763
  page,
742
764
  pageSize: PAGE_SIZE
743
765
  });
@@ -752,22 +774,38 @@ const DISPLAY_CANDIDATES = ["name", "title", "username", "label", "slug", "email
752
774
  function toDisplayValue(raw) {
753
775
  if (raw == null) return "(empty)";
754
776
  if (typeof raw !== "object") return String(raw);
755
- if (Array.isArray(raw)) {
756
- if (raw.length === 0) return "(empty)";
757
- return raw.map((item) => toDisplayValue(item)).join(", ");
758
- }
759
777
  const obj = raw;
760
778
  for (const key of DISPLAY_CANDIDATES) {
761
779
  if (obj[key] != null) return String(obj[key]);
762
780
  }
763
781
  return obj.id != null ? String(obj.id) : "(empty)";
764
782
  }
765
- function groupDocs(docs, fieldPath) {
783
+ function groupDocs(docs, resolved) {
766
784
  const counts = /* @__PURE__ */ new Map();
785
+ const topField = resolved.resolvedPath.split(".")[0];
786
+ const subPath = resolved.resolvedPath.split(".").slice(1).join(".");
767
787
  for (const doc of docs) {
768
- const raw = resolveField(doc, fieldPath);
769
- const value = toDisplayValue(raw);
770
- counts.set(value, (counts.get(value) ?? 0) + 1);
788
+ if (resolved.isArray) {
789
+ const items = doc[topField];
790
+ if (!Array.isArray(items) || items.length === 0) {
791
+ counts.set("(empty)", (counts.get("(empty)") ?? 0) + 1);
792
+ continue;
793
+ }
794
+ for (const item of items) {
795
+ let value;
796
+ if (subPath && typeof item === "object" && item != null) {
797
+ const sub = resolveField(item, subPath);
798
+ value = sub != null ? String(sub) : toDisplayValue(item);
799
+ } else {
800
+ value = toDisplayValue(item);
801
+ }
802
+ counts.set(value, (counts.get(value) ?? 0) + 1);
803
+ }
804
+ } else {
805
+ const raw = resolveField(doc, resolved.resolvedPath);
806
+ const value = toDisplayValue(raw);
807
+ counts.set(value, (counts.get(value) ?? 0) + 1);
808
+ }
771
809
  }
772
810
  return Array.from(counts.entries()).map(([value, count]) => ({ value, count })).sort((a, b) => b.count - a.count);
773
811
  }
@@ -806,10 +844,11 @@ async function aggregateContent(strapi, params) {
806
844
  if (!groupByField) {
807
845
  throw new Error("groupByField is required for countByField operation");
808
846
  }
809
- const { resolvedPath } = resolveFieldPath(strapi, contentType, groupByField);
810
- const docs = await paginateAll(strapi, contentType, baseQuery);
811
- const groups = groupDocs(docs, resolvedPath);
812
- return { total: docs.length, groups, resolvedField: resolvedPath };
847
+ const resolved = resolveFieldPath(strapi, contentType, groupByField);
848
+ const populate = buildPopulate(resolved);
849
+ const docs = await paginateAll(strapi, contentType, baseQuery, populate);
850
+ const groups = groupDocs(docs, resolved);
851
+ return { total: docs.length, groups, resolvedField: resolved.resolvedPath };
813
852
  }
814
853
  case "countByDateRange": {
815
854
  const { dateField = "createdAt", granularity = "month" } = params;
@@ -2154,8 +2193,26 @@ function describeTools(tools) {
2154
2193
  return `Available tools:
2155
2194
  ${lines.join("\n")}`;
2156
2195
  }
2157
- const DEFAULT_PREAMBLE = 'You are a Strapi CMS assistant. Use your tools to fulfill user requests. When asked to create or update content, use the appropriate tool — do not tell the user you cannot. When performing bulk operations (e.g. publish multiple items), call multiple tools in parallel in a single step rather than one at a time.\n\nFor analytics and counting questions (e.g. "how many", "count", "distribution", "trends", "breakdown"), use the aggregateContent tool instead of searchContent — it is faster and purpose-built for these queries. Present analytics results as markdown tables. After showing analytics results, suggest 2-3 follow-up questions the user might find useful under a "**You might also want to know:**" heading.';
2158
- const DEFAULT_PUBLIC_PREAMBLE = 'You are a helpful public assistant for this website. Use your tools to answer questions about the site content. You cannot modify any content or perform administrative actions.\n\nFor analytics and counting questions (e.g. "how many", "count", "distribution", "trends", "breakdown"), use the aggregateContent tool instead of searchContent — it is faster and purpose-built for these queries. Present analytics results as markdown tables. After showing analytics results, suggest 2-3 follow-up questions the user might find useful under a "**You might also want to know:**" heading.';
2196
+ const DEFAULT_PREAMBLE = `You are a Strapi CMS assistant. Use your tools to fulfill user requests. When asked to create or update content, use the appropriate tool — do not tell the user you cannot. When performing bulk operations (e.g. publish multiple items), call multiple tools in parallel in a single step rather than one at a time.
2197
+
2198
+ For analytics and counting questions (e.g. "how many", "count", "distribution", "trends", "breakdown"), use the aggregateContent tool instead of searchContent — it is faster and purpose-built for these queries. Present analytics results as markdown tables. After showing analytics results, suggest 2-3 follow-up questions the user might find useful under a "**You might also want to know:**" heading.
2199
+
2200
+ Strapi filter syntax for searchContent and aggregateContent:
2201
+ - Scalar fields: { title: { $containsi: "hello" } }
2202
+ - Relation (manyToOne): { author: { name: { $eq: "John" } } }
2203
+ - Relation (manyToMany): { contentTags: { title: { $eq: "tutorial" } } }
2204
+ - Always nest relation filters as: { relationField: { fieldOnRelatedType: { $operator: value } } }
2205
+ - Never use flat dot-path syntax like "contentTags.title" in filters — always use nested objects.`;
2206
+ const DEFAULT_PUBLIC_PREAMBLE = `You are a helpful public assistant for this website. Use your tools to answer questions about the site content. You cannot modify any content or perform administrative actions.
2207
+
2208
+ For analytics and counting questions (e.g. "how many", "count", "distribution", "trends", "breakdown"), use the aggregateContent tool instead of searchContent — it is faster and purpose-built for these queries. Present analytics results as markdown tables. After showing analytics results, suggest 2-3 follow-up questions the user might find useful under a "**You might also want to know:**" heading.
2209
+
2210
+ Strapi filter syntax for searchContent and aggregateContent:
2211
+ - Scalar fields: { title: { $containsi: "hello" } }
2212
+ - Relation (manyToOne): { author: { name: { $eq: "John" } } }
2213
+ - Relation (manyToMany): { contentTags: { title: { $eq: "tutorial" } } }
2214
+ - Always nest relation filters as: { relationField: { fieldOnRelatedType: { $operator: value } } }
2215
+ - Never use flat dot-path syntax like "contentTags.title" in filters — always use nested objects.`;
2159
2216
  function composeSystemPrompt(config2, toolsDescription, override) {
2160
2217
  const base = override || config2?.systemPrompt || DEFAULT_PREAMBLE;
2161
2218
  if (base.includes("{tools}")) {
@@ -277,7 +277,7 @@ const searchContentSchema = z.object({
277
277
  'The content type UID to search, e.g. "api::article.article" or "plugin::users-permissions.user"'
278
278
  ),
279
279
  query: z.string().optional().describe("Full-text search query string (searches across all searchable text fields)"),
280
- filters: z.record(z.string(), z.unknown()).optional().describe('Strapi filter object, e.g. { username: { $containsi: "john" } }'),
280
+ filters: z.record(z.string(), z.unknown()).optional().describe('Strapi filter object. Scalar: { title: { $containsi: "hello" } }. Relation: { author: { name: { $eq: "John" } } }. ManyToMany: { contentTags: { title: { $eq: "tutorial" } } }. Operators: $eq, $ne, $containsi, $in, $gt, $lt, $gte, $lte, $null, $notNull.'),
281
281
  fields: z.array(z.string()).optional().describe("Specific fields to return. If omitted, returns all fields (large content fields stripped unless includeContent is true)."),
282
282
  sort: z.string().optional().describe('Sort order, e.g. "createdAt:desc"'),
283
283
  page: z.number().optional().default(1).describe("Page number (starts at 1)"),
@@ -287,7 +287,7 @@ const searchContentSchema = z.object({
287
287
  populate: z.union([z.string(), z.array(z.string()), z.record(z.string(), z.unknown())]).optional().describe('Relations to populate. Defaults to "*" (all). Can be a string, array, or object.'),
288
288
  includeContent: z.boolean().optional().default(false).describe("When true, includes large content fields (content, blocks, body, etc.) in results. Default false to reduce context size.")
289
289
  });
290
- const searchContentDescription = 'Search and query any Strapi content type. Use listContentTypes first to discover available content types and their fields, then use this tool to query specific collections. Use sort (e.g. "createdAt:desc") and pageSize: 1 to get the latest entry. By default, large content fields are stripped from results — set includeContent to true or use fields to get full content.';
290
+ const searchContentDescription = 'Search and query any Strapi content type. Use listContentTypes first to discover available content types and their fields, then use this tool to query specific collections. Use sort (e.g. "createdAt:desc") and pageSize: 1 to get the latest entry. By default, large content fields are stripped from results — set includeContent to true or use fields to get full content. To filter by a relation, nest the filter: { relationField: { fieldOnRelation: { $operator: "value" } } }.';
291
291
  function stripLargeFields(obj) {
292
292
  const stripped = {};
293
293
  for (const [key, value] of Object.entries(obj)) {
@@ -651,24 +651,33 @@ function getDisplayField(strapi, contentType) {
651
651
  }
652
652
  return "id";
653
653
  }
654
+ const MANY_RELATIONS = /* @__PURE__ */ new Set(["oneToMany", "manyToMany"]);
654
655
  function resolveFieldPath(strapi, contentType, fieldPath) {
655
656
  const parts = fieldPath.split(".");
656
657
  const topField = parts[0];
657
658
  const attr = getAttribute(strapi, contentType, topField);
658
659
  if (!attr) {
659
- return { resolvedPath: fieldPath, populate: [] };
660
+ return { resolvedPath: fieldPath, populate: [], isArray: false };
660
661
  }
661
662
  if (attr.type === "relation" && attr.target) {
663
+ const isArray = MANY_RELATIONS.has(attr.relation ?? "");
662
664
  if (parts.length === 1) {
663
665
  const displayField = getDisplayField(strapi, attr.target);
664
666
  return {
665
667
  resolvedPath: `${topField}.${displayField}`,
666
- populate: [topField]
668
+ populate: [topField],
669
+ isArray,
670
+ selectField: displayField
667
671
  };
668
672
  }
669
- return { resolvedPath: fieldPath, populate: [topField] };
673
+ return {
674
+ resolvedPath: fieldPath,
675
+ populate: [topField],
676
+ isArray,
677
+ selectField: parts.slice(1).join(".")
678
+ };
670
679
  }
671
- return { resolvedPath: fieldPath, populate: [] };
680
+ return { resolvedPath: fieldPath, populate: [], isArray: false };
672
681
  }
673
682
  const MAX_PAGINATE = 1e3;
674
683
  const PAGE_SIZE = 100;
@@ -677,7 +686,7 @@ const aggregateContentSchema = z.object({
677
686
  operation: z.enum(["count", "countByField", "countByDateRange"]).describe(
678
687
  "count — total count; countByField — group by a field; countByDateRange — bucket by date"
679
688
  ),
680
- filters: z.record(z.string(), z.unknown()).optional().describe('Strapi filter object, e.g. { category: { name: "tech" } }'),
689
+ filters: z.record(z.string(), z.unknown()).optional().describe('Strapi filter object. Scalar: { title: { $containsi: "hello" } }. Relation: { author: { name: { $eq: "John" } } }. ManyToMany: { contentTags: { title: { $eq: "tutorial" } } }.'),
681
690
  groupByField: z.string().optional().describe('Field to group by for countByField. Just use the field name — relation fields are auto-resolved to their display name (e.g. "author", "category"). You can also use dot paths like "author.email" for a specific sub-field.'),
682
691
  dateField: z.string().optional().default("createdAt").describe('Date field for countByDateRange (default: "createdAt")'),
683
692
  granularity: z.enum(["day", "week", "month"]).optional().default("month").describe("Bucket granularity for countByDateRange"),
@@ -707,17 +716,30 @@ function resolveField(doc, fieldPath) {
707
716
  let current = doc;
708
717
  for (const part of parts) {
709
718
  if (current == null || typeof current !== "object") return void 0;
719
+ if (Array.isArray(current)) return void 0;
710
720
  current = current[part];
711
721
  }
712
722
  return current;
713
723
  }
714
- async function paginateAll(strapi, contentType, baseQuery) {
724
+ function buildPopulate(resolved) {
725
+ if (resolved.populate.length === 0) return "*";
726
+ const populate = {};
727
+ for (const rel of resolved.populate) {
728
+ if (resolved.selectField) {
729
+ populate[rel] = { fields: [resolved.selectField] };
730
+ } else {
731
+ populate[rel] = true;
732
+ }
733
+ }
734
+ return populate;
735
+ }
736
+ async function paginateAll(strapi, contentType, baseQuery, populate = "*") {
715
737
  const docs = [];
716
738
  let page = 1;
717
739
  while (docs.length < MAX_PAGINATE) {
718
740
  const results = await strapi.documents(contentType).findMany({
719
741
  ...baseQuery,
720
- populate: "*",
742
+ populate,
721
743
  page,
722
744
  pageSize: PAGE_SIZE
723
745
  });
@@ -732,22 +754,38 @@ const DISPLAY_CANDIDATES = ["name", "title", "username", "label", "slug", "email
732
754
  function toDisplayValue(raw) {
733
755
  if (raw == null) return "(empty)";
734
756
  if (typeof raw !== "object") return String(raw);
735
- if (Array.isArray(raw)) {
736
- if (raw.length === 0) return "(empty)";
737
- return raw.map((item) => toDisplayValue(item)).join(", ");
738
- }
739
757
  const obj = raw;
740
758
  for (const key of DISPLAY_CANDIDATES) {
741
759
  if (obj[key] != null) return String(obj[key]);
742
760
  }
743
761
  return obj.id != null ? String(obj.id) : "(empty)";
744
762
  }
745
- function groupDocs(docs, fieldPath) {
763
+ function groupDocs(docs, resolved) {
746
764
  const counts = /* @__PURE__ */ new Map();
765
+ const topField = resolved.resolvedPath.split(".")[0];
766
+ const subPath = resolved.resolvedPath.split(".").slice(1).join(".");
747
767
  for (const doc of docs) {
748
- const raw = resolveField(doc, fieldPath);
749
- const value = toDisplayValue(raw);
750
- counts.set(value, (counts.get(value) ?? 0) + 1);
768
+ if (resolved.isArray) {
769
+ const items = doc[topField];
770
+ if (!Array.isArray(items) || items.length === 0) {
771
+ counts.set("(empty)", (counts.get("(empty)") ?? 0) + 1);
772
+ continue;
773
+ }
774
+ for (const item of items) {
775
+ let value;
776
+ if (subPath && typeof item === "object" && item != null) {
777
+ const sub = resolveField(item, subPath);
778
+ value = sub != null ? String(sub) : toDisplayValue(item);
779
+ } else {
780
+ value = toDisplayValue(item);
781
+ }
782
+ counts.set(value, (counts.get(value) ?? 0) + 1);
783
+ }
784
+ } else {
785
+ const raw = resolveField(doc, resolved.resolvedPath);
786
+ const value = toDisplayValue(raw);
787
+ counts.set(value, (counts.get(value) ?? 0) + 1);
788
+ }
751
789
  }
752
790
  return Array.from(counts.entries()).map(([value, count]) => ({ value, count })).sort((a, b) => b.count - a.count);
753
791
  }
@@ -786,10 +824,11 @@ async function aggregateContent(strapi, params) {
786
824
  if (!groupByField) {
787
825
  throw new Error("groupByField is required for countByField operation");
788
826
  }
789
- const { resolvedPath } = resolveFieldPath(strapi, contentType, groupByField);
790
- const docs = await paginateAll(strapi, contentType, baseQuery);
791
- const groups = groupDocs(docs, resolvedPath);
792
- return { total: docs.length, groups, resolvedField: resolvedPath };
827
+ const resolved = resolveFieldPath(strapi, contentType, groupByField);
828
+ const populate = buildPopulate(resolved);
829
+ const docs = await paginateAll(strapi, contentType, baseQuery, populate);
830
+ const groups = groupDocs(docs, resolved);
831
+ return { total: docs.length, groups, resolvedField: resolved.resolvedPath };
793
832
  }
794
833
  case "countByDateRange": {
795
834
  const { dateField = "createdAt", granularity = "month" } = params;
@@ -2134,8 +2173,26 @@ function describeTools(tools) {
2134
2173
  return `Available tools:
2135
2174
  ${lines.join("\n")}`;
2136
2175
  }
2137
- const DEFAULT_PREAMBLE = 'You are a Strapi CMS assistant. Use your tools to fulfill user requests. When asked to create or update content, use the appropriate tool — do not tell the user you cannot. When performing bulk operations (e.g. publish multiple items), call multiple tools in parallel in a single step rather than one at a time.\n\nFor analytics and counting questions (e.g. "how many", "count", "distribution", "trends", "breakdown"), use the aggregateContent tool instead of searchContent — it is faster and purpose-built for these queries. Present analytics results as markdown tables. After showing analytics results, suggest 2-3 follow-up questions the user might find useful under a "**You might also want to know:**" heading.';
2138
- const DEFAULT_PUBLIC_PREAMBLE = 'You are a helpful public assistant for this website. Use your tools to answer questions about the site content. You cannot modify any content or perform administrative actions.\n\nFor analytics and counting questions (e.g. "how many", "count", "distribution", "trends", "breakdown"), use the aggregateContent tool instead of searchContent — it is faster and purpose-built for these queries. Present analytics results as markdown tables. After showing analytics results, suggest 2-3 follow-up questions the user might find useful under a "**You might also want to know:**" heading.';
2176
+ const DEFAULT_PREAMBLE = `You are a Strapi CMS assistant. Use your tools to fulfill user requests. When asked to create or update content, use the appropriate tool — do not tell the user you cannot. When performing bulk operations (e.g. publish multiple items), call multiple tools in parallel in a single step rather than one at a time.
2177
+
2178
+ For analytics and counting questions (e.g. "how many", "count", "distribution", "trends", "breakdown"), use the aggregateContent tool instead of searchContent — it is faster and purpose-built for these queries. Present analytics results as markdown tables. After showing analytics results, suggest 2-3 follow-up questions the user might find useful under a "**You might also want to know:**" heading.
2179
+
2180
+ Strapi filter syntax for searchContent and aggregateContent:
2181
+ - Scalar fields: { title: { $containsi: "hello" } }
2182
+ - Relation (manyToOne): { author: { name: { $eq: "John" } } }
2183
+ - Relation (manyToMany): { contentTags: { title: { $eq: "tutorial" } } }
2184
+ - Always nest relation filters as: { relationField: { fieldOnRelatedType: { $operator: value } } }
2185
+ - Never use flat dot-path syntax like "contentTags.title" in filters — always use nested objects.`;
2186
+ const DEFAULT_PUBLIC_PREAMBLE = `You are a helpful public assistant for this website. Use your tools to answer questions about the site content. You cannot modify any content or perform administrative actions.
2187
+
2188
+ For analytics and counting questions (e.g. "how many", "count", "distribution", "trends", "breakdown"), use the aggregateContent tool instead of searchContent — it is faster and purpose-built for these queries. Present analytics results as markdown tables. After showing analytics results, suggest 2-3 follow-up questions the user might find useful under a "**You might also want to know:**" heading.
2189
+
2190
+ Strapi filter syntax for searchContent and aggregateContent:
2191
+ - Scalar fields: { title: { $containsi: "hello" } }
2192
+ - Relation (manyToOne): { author: { name: { $eq: "John" } } }
2193
+ - Relation (manyToMany): { contentTags: { title: { $eq: "tutorial" } } }
2194
+ - Always nest relation filters as: { relationField: { fieldOnRelatedType: { $operator: value } } }
2195
+ - Never use flat dot-path syntax like "contentTags.title" in filters — always use nested objects.`;
2139
2196
  function composeSystemPrompt(config2, toolsDescription, override) {
2140
2197
  const base = override || config2?.systemPrompt || DEFAULT_PREAMBLE;
2141
2198
  if (base.includes("{tools}")) {
@@ -19,3 +19,4 @@ export type { RecallPublicMemoriesParams, RecallPublicMemoriesResult } from './r
19
19
  export { aggregateContent, aggregateContentSchema, aggregateContentDescription } from './aggregate-content';
20
20
  export type { AggregateContentParams, AggregateContentResult } from './aggregate-content';
21
21
  export { resolveFieldPath, getDisplayField, isRelation, getRelationTarget, getSchema } from './schema-utils';
22
+ export type { ResolvedField } from './schema-utils';
@@ -35,17 +35,15 @@ export declare function getRelationTarget(strapi: Core.Strapi, contentType: stri
35
35
  * then falls back to the first string/text field, then 'id'.
36
36
  */
37
37
  export declare function getDisplayField(strapi: Core.Strapi, contentType: string): string;
38
- /**
39
- * Given a field path that the AI provided (e.g. "author", "category", "author.name"),
40
- * resolve it to the actual dot-path needed to extract a display value from a populated document.
41
- *
42
- * If the field is a relation with no sub-field specified, appends the best display field
43
- * from the target schema (e.g. "author" → "author.name").
44
- *
45
- * Returns the resolved path and any top-level relations that need populating.
46
- */
47
- export declare function resolveFieldPath(strapi: Core.Strapi, contentType: string, fieldPath: string): {
38
+ export interface ResolvedField {
39
+ /** The dot-path to extract the display value (e.g. "author.name") */
48
40
  resolvedPath: string;
41
+ /** Top-level relations that must be populated */
49
42
  populate: string[];
50
- };
43
+ /** True if the relation is a *ToMany (value is an array of items) */
44
+ isArray: boolean;
45
+ /** The sub-field to select from the relation (for targeted populate) */
46
+ selectField?: string;
47
+ }
48
+ export declare function resolveFieldPath(strapi: Core.Strapi, contentType: string, fieldPath: string): ResolvedField;
51
49
  export {};
@@ -16,7 +16,7 @@ export declare const searchContentSchema: z.ZodObject<{
16
16
  populate: z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>, z.ZodRecord<z.ZodString, z.ZodUnknown>]>>;
17
17
  includeContent: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
18
18
  }, z.core.$strip>;
19
- export declare const searchContentDescription = "Search and query any Strapi content type. Use listContentTypes first to discover available content types and their fields, then use this tool to query specific collections. Use sort (e.g. \"createdAt:desc\") and pageSize: 1 to get the latest entry. By default, large content fields are stripped from results \u2014 set includeContent to true or use fields to get full content.";
19
+ export declare const searchContentDescription = "Search and query any Strapi content type. Use listContentTypes first to discover available content types and their fields, then use this tool to query specific collections. Use sort (e.g. \"createdAt:desc\") and pageSize: 1 to get the latest entry. By default, large content fields are stripped from results \u2014 set includeContent to true or use fields to get full content. To filter by a relation, nest the filter: { relationField: { fieldOnRelation: { $operator: \"value\" } } }.";
20
20
  export interface SearchContentParams {
21
21
  contentType: string;
22
22
  query?: string;
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.6.7",
2
+ "version": "0.6.9",
3
3
  "keywords": [
4
4
  "strapi",
5
5
  "strapi-plugin",