strapi-plugin-ai-sdk 0.6.6 → 0.6.7

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,12 +6,14 @@ 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-CZYebcrb.js");
9
+ const index = require("./index-tCUlQhF4.js");
10
10
  const icons = require("@strapi/icons");
11
11
  const Markdown = require("react-markdown");
12
+ const remarkGfm = require("remark-gfm");
12
13
  const _interopDefault = (e) => e && e.__esModule ? e : { default: e };
13
14
  const styled__default = /* @__PURE__ */ _interopDefault(styled);
14
15
  const Markdown__default = /* @__PURE__ */ _interopDefault(Markdown);
16
+ const remarkGfm__default = /* @__PURE__ */ _interopDefault(remarkGfm);
15
17
  const COOKIE_REGEX = /(?:^|;\s*)jwtToken=([^;]*)/;
16
18
  function getCookieValue() {
17
19
  const match = COOKIE_REGEX.exec(document.cookie);
@@ -813,8 +815,9 @@ const MarkdownBody = styled__default.default.div`
813
815
  opacity: 0.85;
814
816
  }
815
817
  a { color: ${({ $isUser }) => $isUser ? "#c0cfff" : "#4945ff"}; }
816
- table { border-collapse: collapse; margin: 8px 0; font-size: 0.9em; }
817
- th, td { border: 1px solid ${({ $isUser }) => $isUser ? "rgba(255,255,255,0.2)" : "#dcdce4"}; padding: 4px 8px; }
818
+ table { border-collapse: collapse; margin: 8px 0; font-size: 0.9em; width: 100%; overflow-x: auto; display: block; }
819
+ th, td { border: 1px solid ${({ $isUser }) => $isUser ? "rgba(255,255,255,0.2)" : "#dcdce4"}; padding: 4px 8px; text-align: left; white-space: nowrap; }
820
+ th { background: ${({ $isUser }) => $isUser ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.03)"}; font-weight: 600; }
818
821
  `;
819
822
  const MessageRole = styled__default.default.div`
820
823
  font-size: 11px;
@@ -888,7 +891,7 @@ const MessageList = react.forwardRef(
888
891
  /* @__PURE__ */ jsxRuntime.jsxs(MessageBubble, { $isUser: message.role === "user", children: [
889
892
  /* @__PURE__ */ jsxRuntime.jsx(MessageRole, { $isUser: message.role === "user", children: message.role === "user" ? "You" : "Assistant" }),
890
893
  message.role === "user" && message.content,
891
- message.role === "assistant" && displayContent && /* @__PURE__ */ jsxRuntime.jsx(MarkdownBody, { $isUser: false, children: /* @__PURE__ */ jsxRuntime.jsx(Markdown__default.default, { components: markdownComponents, children: displayContent }) }),
894
+ message.role === "assistant" && displayContent && /* @__PURE__ */ jsxRuntime.jsx(MarkdownBody, { $isUser: false, children: /* @__PURE__ */ jsxRuntime.jsx(Markdown__default.default, { remarkPlugins: [remarkGfm__default.default], components: markdownComponents, children: displayContent }) }),
892
895
  message.role === "assistant" && !displayContent && isLoading && /* @__PURE__ */ jsxRuntime.jsxs(TypingDots, { children: [
893
896
  /* @__PURE__ */ jsxRuntime.jsx("span", {}),
894
897
  /* @__PURE__ */ jsxRuntime.jsx("span", {}),
@@ -4,9 +4,10 @@ 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-DzpIKOjG.mjs";
7
+ import { P as PLUGIN_ID } from "./index-C6UZ9D-c.mjs";
8
8
  import { Plus, Trash, Sparkle, ArrowLeft, Pencil } from "@strapi/icons";
9
9
  import Markdown from "react-markdown";
10
+ import remarkGfm from "remark-gfm";
10
11
  const COOKIE_REGEX = /(?:^|;\s*)jwtToken=([^;]*)/;
11
12
  function getCookieValue() {
12
13
  const match = COOKIE_REGEX.exec(document.cookie);
@@ -808,8 +809,9 @@ const MarkdownBody = styled.div`
808
809
  opacity: 0.85;
809
810
  }
810
811
  a { color: ${({ $isUser }) => $isUser ? "#c0cfff" : "#4945ff"}; }
811
- table { border-collapse: collapse; margin: 8px 0; font-size: 0.9em; }
812
- th, td { border: 1px solid ${({ $isUser }) => $isUser ? "rgba(255,255,255,0.2)" : "#dcdce4"}; padding: 4px 8px; }
812
+ table { border-collapse: collapse; margin: 8px 0; font-size: 0.9em; width: 100%; overflow-x: auto; display: block; }
813
+ th, td { border: 1px solid ${({ $isUser }) => $isUser ? "rgba(255,255,255,0.2)" : "#dcdce4"}; padding: 4px 8px; text-align: left; white-space: nowrap; }
814
+ th { background: ${({ $isUser }) => $isUser ? "rgba(255,255,255,0.1)" : "rgba(0,0,0,0.03)"}; font-weight: 600; }
813
815
  `;
814
816
  const MessageRole = styled.div`
815
817
  font-size: 11px;
@@ -883,7 +885,7 @@ const MessageList = forwardRef(
883
885
  /* @__PURE__ */ jsxs(MessageBubble, { $isUser: message.role === "user", children: [
884
886
  /* @__PURE__ */ jsx(MessageRole, { $isUser: message.role === "user", children: message.role === "user" ? "You" : "Assistant" }),
885
887
  message.role === "user" && message.content,
886
- message.role === "assistant" && displayContent && /* @__PURE__ */ jsx(MarkdownBody, { $isUser: false, children: /* @__PURE__ */ jsx(Markdown, { components: markdownComponents, children: displayContent }) }),
888
+ message.role === "assistant" && displayContent && /* @__PURE__ */ jsx(MarkdownBody, { $isUser: false, children: /* @__PURE__ */ jsx(Markdown, { remarkPlugins: [remarkGfm], components: markdownComponents, children: displayContent }) }),
887
889
  message.role === "assistant" && !displayContent && isLoading && /* @__PURE__ */ jsxs(TypingDots, { children: [
888
890
  /* @__PURE__ */ jsx("span", {}),
889
891
  /* @__PURE__ */ jsx("span", {}),
@@ -36,7 +36,7 @@ const index = {
36
36
  defaultMessage: PLUGIN_ID
37
37
  },
38
38
  Component: async () => {
39
- const { App } = await import("./App-T05Erwmh.mjs");
39
+ const { App } = await import("./App-k5doNU8o.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-Dg01IPnx.js"));
40
+ const { App } = await Promise.resolve().then(() => require("./App-31DoeBYs.js"));
41
41
  return App;
42
42
  }
43
43
  });
@@ -1,3 +1,3 @@
1
1
  "use strict";
2
- const index = require("../_chunks/index-CZYebcrb.js");
2
+ const index = require("../_chunks/index-tCUlQhF4.js");
3
3
  module.exports = index.index;
@@ -1,4 +1,4 @@
1
- import { i } from "../_chunks/index-DzpIKOjG.mjs";
1
+ import { i } from "../_chunks/index-C6UZ9D-c.mjs";
2
2
  export {
3
3
  i as default
4
4
  };
@@ -647,6 +647,187 @@ async function recallPublicMemories(strapi, params) {
647
647
  return { success: false, memories: [], count: 0 };
648
648
  }
649
649
  }
650
+ const DISPLAY_CANDIDATES$1 = ["name", "title", "username", "label", "slug", "email"];
651
+ function getSchema(strapi, contentType) {
652
+ return strapi.contentTypes[contentType];
653
+ }
654
+ function getAttribute(strapi, contentType, field) {
655
+ const schema2 = getSchema(strapi, contentType);
656
+ return schema2?.attributes?.[field];
657
+ }
658
+ function getDisplayField(strapi, contentType) {
659
+ const schema2 = getSchema(strapi, contentType);
660
+ if (!schema2?.attributes) return "id";
661
+ for (const candidate of DISPLAY_CANDIDATES$1) {
662
+ const attr = schema2.attributes[candidate];
663
+ if (attr && (attr.type === "string" || attr.type === "text" || attr.type === "email")) {
664
+ return candidate;
665
+ }
666
+ }
667
+ for (const [name, attr] of Object.entries(schema2.attributes)) {
668
+ if (attr.type === "string" || attr.type === "text") {
669
+ return name;
670
+ }
671
+ }
672
+ return "id";
673
+ }
674
+ function resolveFieldPath(strapi, contentType, fieldPath) {
675
+ const parts = fieldPath.split(".");
676
+ const topField = parts[0];
677
+ const attr = getAttribute(strapi, contentType, topField);
678
+ if (!attr) {
679
+ return { resolvedPath: fieldPath, populate: [] };
680
+ }
681
+ if (attr.type === "relation" && attr.target) {
682
+ if (parts.length === 1) {
683
+ const displayField = getDisplayField(strapi, attr.target);
684
+ return {
685
+ resolvedPath: `${topField}.${displayField}`,
686
+ populate: [topField]
687
+ };
688
+ }
689
+ return { resolvedPath: fieldPath, populate: [topField] };
690
+ }
691
+ return { resolvedPath: fieldPath, populate: [] };
692
+ }
693
+ const MAX_PAGINATE = 1e3;
694
+ const PAGE_SIZE = 100;
695
+ const aggregateContentSchema = zod.z.object({
696
+ contentType: zod.z.string().describe('The content type UID, e.g. "api::article.article"'),
697
+ operation: zod.z.enum(["count", "countByField", "countByDateRange"]).describe(
698
+ "count — total count; countByField — group by a field; countByDateRange — bucket by date"
699
+ ),
700
+ filters: zod.z.record(zod.z.string(), zod.z.unknown()).optional().describe('Strapi filter object, e.g. { category: { name: "tech" } }'),
701
+ 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
+ dateField: zod.z.string().optional().default("createdAt").describe('Date field for countByDateRange (default: "createdAt")'),
703
+ granularity: zod.z.enum(["day", "week", "month"]).optional().default("month").describe("Bucket granularity for countByDateRange"),
704
+ dateFrom: zod.z.string().optional().describe("ISO date string lower bound (inclusive)"),
705
+ dateTo: zod.z.string().optional().describe("ISO date string upper bound (inclusive)"),
706
+ status: zod.z.enum(["draft", "published"]).optional().describe("Filter by document status"),
707
+ locale: zod.z.string().optional().describe("Locale code for i18n content")
708
+ });
709
+ const aggregateContentDescription = 'Aggregate and count content entries. Use for analytics questions like "how many articles per category", "content trends by month", or total counts with filters. Prefer this over searchContent for counting and grouping.';
710
+ function buildBaseQuery(params) {
711
+ const { filters, status, locale, dateFrom, dateTo, dateField = "createdAt" } = params;
712
+ const query = {};
713
+ const mergedFilters = { ...filters };
714
+ if (dateFrom || dateTo) {
715
+ const dateFilter = {};
716
+ if (dateFrom) dateFilter.$gte = dateFrom;
717
+ if (dateTo) dateFilter.$lte = dateTo;
718
+ mergedFilters[dateField] = dateFilter;
719
+ }
720
+ if (Object.keys(mergedFilters).length > 0) query.filters = mergedFilters;
721
+ if (status) query.status = status;
722
+ if (locale) query.locale = locale;
723
+ return query;
724
+ }
725
+ function resolveField(doc, fieldPath) {
726
+ const parts = fieldPath.split(".");
727
+ let current = doc;
728
+ for (const part of parts) {
729
+ if (current == null || typeof current !== "object") return void 0;
730
+ current = current[part];
731
+ }
732
+ return current;
733
+ }
734
+ async function paginateAll(strapi, contentType, baseQuery) {
735
+ const docs = [];
736
+ let page = 1;
737
+ while (docs.length < MAX_PAGINATE) {
738
+ const results = await strapi.documents(contentType).findMany({
739
+ ...baseQuery,
740
+ populate: "*",
741
+ page,
742
+ pageSize: PAGE_SIZE
743
+ });
744
+ if (!results || results.length === 0) break;
745
+ docs.push(...results);
746
+ if (results.length < PAGE_SIZE) break;
747
+ page++;
748
+ }
749
+ return docs;
750
+ }
751
+ const DISPLAY_CANDIDATES = ["name", "title", "username", "label", "slug", "email"];
752
+ function toDisplayValue(raw) {
753
+ if (raw == null) return "(empty)";
754
+ 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
+ const obj = raw;
760
+ for (const key of DISPLAY_CANDIDATES) {
761
+ if (obj[key] != null) return String(obj[key]);
762
+ }
763
+ return obj.id != null ? String(obj.id) : "(empty)";
764
+ }
765
+ function groupDocs(docs, fieldPath) {
766
+ const counts = /* @__PURE__ */ new Map();
767
+ 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);
771
+ }
772
+ return Array.from(counts.entries()).map(([value, count]) => ({ value, count })).sort((a, b) => b.count - a.count);
773
+ }
774
+ function getWeekKey(date) {
775
+ const d = new Date(date);
776
+ const day = d.getDay();
777
+ const diff = d.getDate() - day + (day === 0 ? -6 : 1);
778
+ d.setDate(diff);
779
+ return d.toISOString().slice(0, 10);
780
+ }
781
+ function getBucketKey(dateStr, granularity) {
782
+ const date = new Date(dateStr);
783
+ if (isNaN(date.getTime())) return "unknown";
784
+ switch (granularity) {
785
+ case "day":
786
+ return date.toISOString().slice(0, 10);
787
+ case "week":
788
+ return getWeekKey(date);
789
+ case "month":
790
+ return date.toISOString().slice(0, 7);
791
+ }
792
+ }
793
+ async function aggregateContent(strapi, params) {
794
+ const { contentType, operation } = params;
795
+ if (!strapi.contentTypes[contentType]) {
796
+ throw new Error(`Content type "${contentType}" does not exist.`);
797
+ }
798
+ const baseQuery = buildBaseQuery(params);
799
+ switch (operation) {
800
+ case "count": {
801
+ const total = await strapi.documents(contentType).count(baseQuery);
802
+ return { total };
803
+ }
804
+ case "countByField": {
805
+ const { groupByField } = params;
806
+ if (!groupByField) {
807
+ throw new Error("groupByField is required for countByField operation");
808
+ }
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 };
813
+ }
814
+ case "countByDateRange": {
815
+ const { dateField = "createdAt", granularity = "month" } = params;
816
+ const docs = await paginateAll(strapi, contentType, baseQuery);
817
+ const counts = /* @__PURE__ */ new Map();
818
+ for (const doc of docs) {
819
+ const raw = resolveField(doc, dateField);
820
+ if (!raw) continue;
821
+ const key = getBucketKey(String(raw), granularity);
822
+ counts.set(key, (counts.get(key) ?? 0) + 1);
823
+ }
824
+ const buckets = Array.from(counts.entries()).map(([period, count]) => ({ period, count })).sort((a, b) => a.period.localeCompare(b.period));
825
+ return { total: docs.length, buckets };
826
+ }
827
+ default:
828
+ throw new Error(`Unknown operation: ${operation}`);
829
+ }
830
+ }
650
831
  const listContentTypesTool = {
651
832
  name: "listContentTypes",
652
833
  description: listContentTypesDescription,
@@ -752,6 +933,13 @@ const uploadMediaTool = {
752
933
  schema: uploadMediaSchema,
753
934
  execute: async (args, strapi) => uploadMedia(strapi, args)
754
935
  };
936
+ const aggregateContentTool = {
937
+ name: "aggregateContent",
938
+ description: aggregateContentDescription,
939
+ schema: aggregateContentSchema,
940
+ execute: async (args, strapi) => aggregateContent(strapi, args),
941
+ publicSafe: true
942
+ };
755
943
  const builtInTools = [
756
944
  listContentTypesTool,
757
945
  searchContentTool,
@@ -761,7 +949,8 @@ const builtInTools = [
761
949
  sendEmailTool,
762
950
  saveMemoryTool,
763
951
  recallMemoriesTool,
764
- recallPublicMemoriesTool
952
+ recallPublicMemoriesTool,
953
+ aggregateContentTool
765
954
  ];
766
955
  const PLUGIN_ID$2 = "ai-sdk";
767
956
  const bootstrap = ({ strapi }) => {
@@ -1915,7 +2104,7 @@ function createTools(strapi, context) {
1915
2104
  }
1916
2105
  return tools;
1917
2106
  }
1918
- const CONTENT_TOOLS = /* @__PURE__ */ new Set(["searchContent", "findOneContent"]);
2107
+ const CONTENT_TOOLS = /* @__PURE__ */ new Set(["searchContent", "findOneContent", "aggregateContent"]);
1919
2108
  function createPublicTools(strapi, allowedContentTypes) {
1920
2109
  const plugin = strapi.plugin("ai-sdk");
1921
2110
  const registry = plugin.toolRegistry;
@@ -1965,8 +2154,8 @@ function describeTools(tools) {
1965
2154
  return `Available tools:
1966
2155
  ${lines.join("\n")}`;
1967
2156
  }
1968
- 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.";
1969
- 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.";
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.';
1970
2159
  function composeSystemPrompt(config2, toolsDescription, override) {
1971
2160
  const base = override || config2?.systemPrompt || DEFAULT_PREAMBLE;
1972
2161
  if (base.includes("{tools}")) {
@@ -627,6 +627,187 @@ async function recallPublicMemories(strapi, params) {
627
627
  return { success: false, memories: [], count: 0 };
628
628
  }
629
629
  }
630
+ const DISPLAY_CANDIDATES$1 = ["name", "title", "username", "label", "slug", "email"];
631
+ function getSchema(strapi, contentType) {
632
+ return strapi.contentTypes[contentType];
633
+ }
634
+ function getAttribute(strapi, contentType, field) {
635
+ const schema2 = getSchema(strapi, contentType);
636
+ return schema2?.attributes?.[field];
637
+ }
638
+ function getDisplayField(strapi, contentType) {
639
+ const schema2 = getSchema(strapi, contentType);
640
+ if (!schema2?.attributes) return "id";
641
+ for (const candidate of DISPLAY_CANDIDATES$1) {
642
+ const attr = schema2.attributes[candidate];
643
+ if (attr && (attr.type === "string" || attr.type === "text" || attr.type === "email")) {
644
+ return candidate;
645
+ }
646
+ }
647
+ for (const [name, attr] of Object.entries(schema2.attributes)) {
648
+ if (attr.type === "string" || attr.type === "text") {
649
+ return name;
650
+ }
651
+ }
652
+ return "id";
653
+ }
654
+ function resolveFieldPath(strapi, contentType, fieldPath) {
655
+ const parts = fieldPath.split(".");
656
+ const topField = parts[0];
657
+ const attr = getAttribute(strapi, contentType, topField);
658
+ if (!attr) {
659
+ return { resolvedPath: fieldPath, populate: [] };
660
+ }
661
+ if (attr.type === "relation" && attr.target) {
662
+ if (parts.length === 1) {
663
+ const displayField = getDisplayField(strapi, attr.target);
664
+ return {
665
+ resolvedPath: `${topField}.${displayField}`,
666
+ populate: [topField]
667
+ };
668
+ }
669
+ return { resolvedPath: fieldPath, populate: [topField] };
670
+ }
671
+ return { resolvedPath: fieldPath, populate: [] };
672
+ }
673
+ const MAX_PAGINATE = 1e3;
674
+ const PAGE_SIZE = 100;
675
+ const aggregateContentSchema = z.object({
676
+ contentType: z.string().describe('The content type UID, e.g. "api::article.article"'),
677
+ operation: z.enum(["count", "countByField", "countByDateRange"]).describe(
678
+ "count — total count; countByField — group by a field; countByDateRange — bucket by date"
679
+ ),
680
+ filters: z.record(z.string(), z.unknown()).optional().describe('Strapi filter object, e.g. { category: { name: "tech" } }'),
681
+ 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
+ dateField: z.string().optional().default("createdAt").describe('Date field for countByDateRange (default: "createdAt")'),
683
+ granularity: z.enum(["day", "week", "month"]).optional().default("month").describe("Bucket granularity for countByDateRange"),
684
+ dateFrom: z.string().optional().describe("ISO date string lower bound (inclusive)"),
685
+ dateTo: z.string().optional().describe("ISO date string upper bound (inclusive)"),
686
+ status: z.enum(["draft", "published"]).optional().describe("Filter by document status"),
687
+ locale: z.string().optional().describe("Locale code for i18n content")
688
+ });
689
+ const aggregateContentDescription = 'Aggregate and count content entries. Use for analytics questions like "how many articles per category", "content trends by month", or total counts with filters. Prefer this over searchContent for counting and grouping.';
690
+ function buildBaseQuery(params) {
691
+ const { filters, status, locale, dateFrom, dateTo, dateField = "createdAt" } = params;
692
+ const query = {};
693
+ const mergedFilters = { ...filters };
694
+ if (dateFrom || dateTo) {
695
+ const dateFilter = {};
696
+ if (dateFrom) dateFilter.$gte = dateFrom;
697
+ if (dateTo) dateFilter.$lte = dateTo;
698
+ mergedFilters[dateField] = dateFilter;
699
+ }
700
+ if (Object.keys(mergedFilters).length > 0) query.filters = mergedFilters;
701
+ if (status) query.status = status;
702
+ if (locale) query.locale = locale;
703
+ return query;
704
+ }
705
+ function resolveField(doc, fieldPath) {
706
+ const parts = fieldPath.split(".");
707
+ let current = doc;
708
+ for (const part of parts) {
709
+ if (current == null || typeof current !== "object") return void 0;
710
+ current = current[part];
711
+ }
712
+ return current;
713
+ }
714
+ async function paginateAll(strapi, contentType, baseQuery) {
715
+ const docs = [];
716
+ let page = 1;
717
+ while (docs.length < MAX_PAGINATE) {
718
+ const results = await strapi.documents(contentType).findMany({
719
+ ...baseQuery,
720
+ populate: "*",
721
+ page,
722
+ pageSize: PAGE_SIZE
723
+ });
724
+ if (!results || results.length === 0) break;
725
+ docs.push(...results);
726
+ if (results.length < PAGE_SIZE) break;
727
+ page++;
728
+ }
729
+ return docs;
730
+ }
731
+ const DISPLAY_CANDIDATES = ["name", "title", "username", "label", "slug", "email"];
732
+ function toDisplayValue(raw) {
733
+ if (raw == null) return "(empty)";
734
+ 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
+ const obj = raw;
740
+ for (const key of DISPLAY_CANDIDATES) {
741
+ if (obj[key] != null) return String(obj[key]);
742
+ }
743
+ return obj.id != null ? String(obj.id) : "(empty)";
744
+ }
745
+ function groupDocs(docs, fieldPath) {
746
+ const counts = /* @__PURE__ */ new Map();
747
+ 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);
751
+ }
752
+ return Array.from(counts.entries()).map(([value, count]) => ({ value, count })).sort((a, b) => b.count - a.count);
753
+ }
754
+ function getWeekKey(date) {
755
+ const d = new Date(date);
756
+ const day = d.getDay();
757
+ const diff = d.getDate() - day + (day === 0 ? -6 : 1);
758
+ d.setDate(diff);
759
+ return d.toISOString().slice(0, 10);
760
+ }
761
+ function getBucketKey(dateStr, granularity) {
762
+ const date = new Date(dateStr);
763
+ if (isNaN(date.getTime())) return "unknown";
764
+ switch (granularity) {
765
+ case "day":
766
+ return date.toISOString().slice(0, 10);
767
+ case "week":
768
+ return getWeekKey(date);
769
+ case "month":
770
+ return date.toISOString().slice(0, 7);
771
+ }
772
+ }
773
+ async function aggregateContent(strapi, params) {
774
+ const { contentType, operation } = params;
775
+ if (!strapi.contentTypes[contentType]) {
776
+ throw new Error(`Content type "${contentType}" does not exist.`);
777
+ }
778
+ const baseQuery = buildBaseQuery(params);
779
+ switch (operation) {
780
+ case "count": {
781
+ const total = await strapi.documents(contentType).count(baseQuery);
782
+ return { total };
783
+ }
784
+ case "countByField": {
785
+ const { groupByField } = params;
786
+ if (!groupByField) {
787
+ throw new Error("groupByField is required for countByField operation");
788
+ }
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 };
793
+ }
794
+ case "countByDateRange": {
795
+ const { dateField = "createdAt", granularity = "month" } = params;
796
+ const docs = await paginateAll(strapi, contentType, baseQuery);
797
+ const counts = /* @__PURE__ */ new Map();
798
+ for (const doc of docs) {
799
+ const raw = resolveField(doc, dateField);
800
+ if (!raw) continue;
801
+ const key = getBucketKey(String(raw), granularity);
802
+ counts.set(key, (counts.get(key) ?? 0) + 1);
803
+ }
804
+ const buckets = Array.from(counts.entries()).map(([period, count]) => ({ period, count })).sort((a, b) => a.period.localeCompare(b.period));
805
+ return { total: docs.length, buckets };
806
+ }
807
+ default:
808
+ throw new Error(`Unknown operation: ${operation}`);
809
+ }
810
+ }
630
811
  const listContentTypesTool = {
631
812
  name: "listContentTypes",
632
813
  description: listContentTypesDescription,
@@ -732,6 +913,13 @@ const uploadMediaTool = {
732
913
  schema: uploadMediaSchema,
733
914
  execute: async (args, strapi) => uploadMedia(strapi, args)
734
915
  };
916
+ const aggregateContentTool = {
917
+ name: "aggregateContent",
918
+ description: aggregateContentDescription,
919
+ schema: aggregateContentSchema,
920
+ execute: async (args, strapi) => aggregateContent(strapi, args),
921
+ publicSafe: true
922
+ };
735
923
  const builtInTools = [
736
924
  listContentTypesTool,
737
925
  searchContentTool,
@@ -741,7 +929,8 @@ const builtInTools = [
741
929
  sendEmailTool,
742
930
  saveMemoryTool,
743
931
  recallMemoriesTool,
744
- recallPublicMemoriesTool
932
+ recallPublicMemoriesTool,
933
+ aggregateContentTool
745
934
  ];
746
935
  const PLUGIN_ID$2 = "ai-sdk";
747
936
  const bootstrap = ({ strapi }) => {
@@ -1895,7 +2084,7 @@ function createTools(strapi, context) {
1895
2084
  }
1896
2085
  return tools;
1897
2086
  }
1898
- const CONTENT_TOOLS = /* @__PURE__ */ new Set(["searchContent", "findOneContent"]);
2087
+ const CONTENT_TOOLS = /* @__PURE__ */ new Set(["searchContent", "findOneContent", "aggregateContent"]);
1899
2088
  function createPublicTools(strapi, allowedContentTypes) {
1900
2089
  const plugin = strapi.plugin("ai-sdk");
1901
2090
  const registry = plugin.toolRegistry;
@@ -1945,8 +2134,8 @@ function describeTools(tools) {
1945
2134
  return `Available tools:
1946
2135
  ${lines.join("\n")}`;
1947
2136
  }
1948
- 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.";
1949
- 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.";
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.';
1950
2139
  function composeSystemPrompt(config2, toolsDescription, override) {
1951
2140
  const base = override || config2?.systemPrompt || DEFAULT_PREAMBLE;
1952
2141
  if (base.includes("{tools}")) {
@@ -0,0 +1,52 @@
1
+ import type { Core } from '@strapi/strapi';
2
+ import { z } from 'zod';
3
+ export declare const aggregateContentSchema: z.ZodObject<{
4
+ contentType: z.ZodString;
5
+ operation: z.ZodEnum<{
6
+ count: "count";
7
+ countByField: "countByField";
8
+ countByDateRange: "countByDateRange";
9
+ }>;
10
+ filters: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
11
+ groupByField: z.ZodOptional<z.ZodString>;
12
+ dateField: z.ZodDefault<z.ZodOptional<z.ZodString>>;
13
+ granularity: z.ZodDefault<z.ZodOptional<z.ZodEnum<{
14
+ day: "day";
15
+ week: "week";
16
+ month: "month";
17
+ }>>>;
18
+ dateFrom: z.ZodOptional<z.ZodString>;
19
+ dateTo: z.ZodOptional<z.ZodString>;
20
+ status: z.ZodOptional<z.ZodEnum<{
21
+ draft: "draft";
22
+ published: "published";
23
+ }>>;
24
+ locale: z.ZodOptional<z.ZodString>;
25
+ }, z.core.$strip>;
26
+ export declare const aggregateContentDescription = "Aggregate and count content entries. Use for analytics questions like \"how many articles per category\", \"content trends by month\", or total counts with filters. Prefer this over searchContent for counting and grouping.";
27
+ export interface AggregateContentParams {
28
+ contentType: string;
29
+ operation: 'count' | 'countByField' | 'countByDateRange';
30
+ filters?: Record<string, unknown>;
31
+ groupByField?: string;
32
+ dateField?: string;
33
+ granularity?: 'day' | 'week' | 'month';
34
+ dateFrom?: string;
35
+ dateTo?: string;
36
+ status?: 'draft' | 'published';
37
+ locale?: string;
38
+ }
39
+ export interface AggregateContentResult {
40
+ total?: number;
41
+ groups?: {
42
+ value: string;
43
+ count: number;
44
+ }[];
45
+ buckets?: {
46
+ period: string;
47
+ count: number;
48
+ }[];
49
+ /** The field path actually used (after schema auto-resolution) */
50
+ resolvedField?: string;
51
+ }
52
+ export declare function aggregateContent(strapi: Core.Strapi, params: AggregateContentParams): Promise<AggregateContentResult>;
@@ -16,3 +16,6 @@ export { uploadMedia, uploadMediaSchema, uploadMediaDescription } from './upload
16
16
  export type { UploadMediaParams, UploadMediaResult } from './upload-media';
17
17
  export { recallPublicMemories, recallPublicMemoriesSchema, recallPublicMemoriesDescription } from './recall-public-memories';
18
18
  export type { RecallPublicMemoriesParams, RecallPublicMemoriesResult } from './recall-public-memories';
19
+ export { aggregateContent, aggregateContentSchema, aggregateContentDescription } from './aggregate-content';
20
+ export type { AggregateContentParams, AggregateContentResult } from './aggregate-content';
21
+ export { resolveFieldPath, getDisplayField, isRelation, getRelationTarget, getSchema } from './schema-utils';
@@ -0,0 +1,51 @@
1
+ import type { Core } from '@strapi/strapi';
2
+ interface AttributeDef {
3
+ type: string;
4
+ target?: string;
5
+ relation?: string;
6
+ component?: string;
7
+ components?: string[];
8
+ [key: string]: unknown;
9
+ }
10
+ interface ContentTypeSchema {
11
+ attributes?: Record<string, AttributeDef>;
12
+ info?: {
13
+ displayName?: string;
14
+ };
15
+ }
16
+ /**
17
+ * Look up the schema for a content type UID.
18
+ */
19
+ export declare function getSchema(strapi: Core.Strapi, contentType: string): ContentTypeSchema | undefined;
20
+ /**
21
+ * Get the attribute definition for a top-level field on a content type.
22
+ */
23
+ export declare function getAttribute(strapi: Core.Strapi, contentType: string, field: string): AttributeDef | undefined;
24
+ /**
25
+ * Check if a field is a relation on the given content type.
26
+ */
27
+ export declare function isRelation(strapi: Core.Strapi, contentType: string, field: string): boolean;
28
+ /**
29
+ * For a relation field, get the target content type UID.
30
+ */
31
+ export declare function getRelationTarget(strapi: Core.Strapi, contentType: string, field: string): string | undefined;
32
+ /**
33
+ * Determine the best human-readable display field for a content type.
34
+ * Checks common candidates (name, title, username, label, slug, email),
35
+ * then falls back to the first string/text field, then 'id'.
36
+ */
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): {
48
+ resolvedPath: string;
49
+ populate: string[];
50
+ };
51
+ export {};
@@ -0,0 +1,2 @@
1
+ import type { ToolDefinition } from '../../lib/tool-registry';
2
+ export declare const aggregateContentTool: ToolDefinition;
package/package.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "0.6.6",
2
+ "version": "0.6.7",
3
3
  "keywords": [
4
4
  "strapi",
5
5
  "strapi-plugin",