?????? ??? ????????
Mastra + Payload MCP: بناء مساعد لنظام إدارة المحتوى داخل لوحة التحكم

Mastra + Payload MCP: بناء مساعد لنظام إدارة المحتوى داخل لوحة التحكم

أدمج مساعد ذكاء اصطناعي كامل داخل لوحة تحكم Payload: عميل MCP واحد طويل الأمد متصل بمجموعاتك، ومزودو نماذج يضبطها المسؤولون بأنفسهم، وجلسات خاصة لكل مسؤول في نفس قاعدة بيانات MongoDB التي تشغّلها، وموافقات أصلية على الأدوات قبل أي عملية كتابة.

·25 دقيقة قراءة
mastra+payload logo

ماذا لو استطاع مسؤول نظام إدارة المحتوى أن يسأل لوحة التحكم عن بياناتها مباشرة؟ ليس مربع بحث محدود الاحتمالات، بل وكيل حقيقي يقرأ مجموعاتك، ويشرح ما يجده، ويطلب الإذن قبل أن يغيّر أي شيء. والأهم: أن تعيش هذه التجربة كلها داخل لوحة التحكم التي تسجّل الدخول إليها بالفعل، وبنفس قاعدة البيانات التي تشغّلها أصلاً.

يبني هذا الدرس هذا المساعد بالضبط: وكيل ذكاء اصطناعي مدمج داخل لوحة تحكم Payload CMS. يعمل الوكيل بمحرّك Mastra ويتخاطب مع مجموعاتك عبر إضافة Payload MCP. يحصل كل مسؤول مسجَّل الدخول على جلسات خاصة، وتُخزَّن ذاكرة المحادثات في نفس قاعدة بيانات MongoDB التي يستخدمها نظامك بالفعل، أما مزودو النماذج فيُضبطون من داخل نظام إدارة المحتوى نفسه — فإضافة نموذج جديد نموذج إدخال، لا عملية نشر.

ما الذي ستبنيه

  • وكيل Mastra باسم cms-agent يستعلم عن مجموعاتك، وينشئ السجلات أو يحدّثها بعد موافقة صريحة
  • مجموعاتك مكشوفة كأدوات MCP عبر @payloadcms/plugin-mcp، ويقرأها عميل MCP واحد طويل الأمد مربوط على عنوان الاسترجاع المحلي (loopback)
  • مزودو النماذج والنماذج نفسها تُدار كمجموعة في Payload، فيستطيع المسؤول إضافة نموذج دون إعادة نشر
  • سجل المحادثات والجلسات مخزّن في MongoDB عبر MongoDBStore من Mastra
  • عزل الجلسات لكل مسؤول: كل مستخدم يرى خيوطه الخاصة فقط
  • موافقات أصلية على الأدوات تظهر في واجهة المحادثة: القراءة تمرّ، والكتابة تنتظر قرار إنسان
  • واجهة محادثة تُعرض كعرض مخصص في لوحة تحكم Payload، ومنسَّقة بمتغيرات التصميم الخاصة بـ Payload

سأستخدم في هذا الدرس مثالاً تجاريًا صغيرًا بمجموعات products وorders وcustomers. استبدلها بمجموعات مشروعك — النمط نفسه تمامًا.

1. تهيئة المشروع والاعتماديات

ابدأ من أي مشروع Payload 3 مع Next.js (App Router) وقاعدة بيانات MongoDB — استُخدم هنا Payload 3.87 وMastra 1.67. ثم ثبّت حزم Mastra وحزم AI SDK اللازمة للبثّ وإضافة Payload MCP:

npmbash
1npm install @mastra/core @mastra/mcp @mastra/memory @mastra/mongodb @mastra/ai-sdk ai @ai-sdk/react @payloadcms/plugin-mcp

القطعتان الأساسيتان: تتيح @mastra/mcp للوكيل الاتصال بخوادم MCP (وهنا نظام إدارة المحتوى نفسه)، بينما تجسر @mastra/ai-sdk بين وقت تشغيل Mastra وبروتوكول Vercel AI SDK حتى تتمكن واجهة المحادثة من بثّ الرسائل مع أجزاء استدعاء الأدوات.

2. اكشف مجموعاتك كأدوات MCP

تحوّل إضافة Payload MCP المجموعات إلى أدوات Model Context Protocol. اضبطها في payload.config.ts، ولكل مجموعة اختر العمليات التي تريد كشفها (find وcreate وupdate)، والأهم: اكتب وصفًا يقرأه الوكيل ليفهم ما تحتويه المجموعة وكيف ينشئ السجلات بشكل صحيح:

typescript
1import { buildConfig } from "payload";
2import { mongooseAdapter } from "@payloadcms/db-mongodb";
3import { mcpPlugin } from "@payloadcms/plugin-mcp";
4import { hasRole, isAdmin } from "./access-types";
5
6export default buildConfig({
7 // ... your collections: products, orders, customers ...
8 db: mongooseAdapter({ url: process.env.DATABASE_URI || "" }),
9
10 plugins: [
11 mcpPlugin({
12 collections: {
13 products: {
14 enabled: { find: true, create: true, update: true },
15 description:
16 "Products in the catalog. REQUIRED to create: name, price, category.",
17 },
18 orders: {
19 enabled: { find: true, create: true, update: true },
20 description:
21 "Customer orders. REQUIRED to create: customer (a valid customers id), items[] and total. Creating an order recalculates the customer balance.",
22 },
23 customers: {
24 enabled: { find: true, create: true, update: true },
25 description: "Registered customers with contact info and order history.",
26 },
27 },
28 // The plugin's own API-key collection: admins only — but the MCP request
29 // path must still read keys, and it queries without overrideAccess.
30 overrideApiKeyCollection: (collection) => ({
31 ...collection,
32 access: {
33 ...(collection.access ?? {}),
34 read: ({ req }) =>
35 (req as { payloadAPI?: string }).payloadAPI === "MCP" ||
36 hasRole(req.user, "admin"),
37 create: isAdmin,
38 update: isAdmin,
39 delete: isAdmin,
40 admin: ({ req }) => hasRole(req.user, "admin"),
41 },
42 }),
43 }),
44 ],
45});

تكشف الإضافة نقطة نهاية MCP على /api/mcp — وفي تطبيق Payload 3 يتولى ذلك مسار Payload REST الشامل (src/app/(payload)/api/[...slug]/route.ts)، فلا حاجة إلى ملف مسار إضافي. وتصبح كل مجموعة أدوات مُصنَّفة بالاسم مثل findProducts وcreateOrders وupdateCustomers.

وثمّة تفصيلان يظهر أثرهما لاحقًا. الأول أن الأوصاف هي دليل الوكيل: اذكر فيها الحقول المطلوبة والعلاقات (معرّف صحيح من أي مجموعة) وأي آثار جانبية تحدث عند الكتابة — مثل أن إنشاء طلب يعيد حساب رصيد العميل — وإلا حاول النموذج تطبيق تلك الآثار بنفسه عبر استدعاء أداة ثانٍ. والثاني أن تقفل مجموعة مفاتيح API الخاصة بالإضافة عبر overrideApiKeyCollection بحيث لا يدير المفاتيح إلا المسؤولون، مع السماح لمسار طلبات MCP بقراءتها (الفحص عبر req.payloadAPI === "MCP").

3. عميل MCP واحد طويل الأمد على عنوان الاسترجاع المحلي

كانت النسخة الأولى من هذا التكامل تنشئ MCPClient جديدًا لكل رسالة محادثة ثم تغلقه عند انتهاء البثّ. هذا يعمل، لكنه يعيد اكتشاف قائمة الأدوات كاملة في كل رسالة، ويرغمك على تغليف تدفّق الاستجابة فقط لتعرف متى يصبح الإغلاق آمنًا. أما الشكل الإنتاجي فأبسط: أنشئ العميل مرة واحدة لكل عملية على الخادم، واكتشف الأدوات مرة واحدة، وأعد استخدام الاثنين.

typescript
1// src/lib/chat-mcp.ts
2import { MCPClient } from "@mastra/mcp";
3
4/**
5 * The Payload MCP server runs in the SAME Next.js process. Connect over the
6 * loopback address (the public domain goes through a proxy and breaks the MCP
7 * transport), and keep ONE client for the whole server process instead of
8 * reconnecting on every chat turn.
9 */
10const SERVER_NAME = "payload";
11
12function mcpUrl(): URL {
13 const raw =
14 process.env.MCP_SERVER_URL ||
15 `http://127.0.0.1:${process.env.PORT || 3000}/api/mcp`;
16 return new URL(raw);
17}
18
19/** Tools whose names start with these verbs never mutate data. */
20function isReadOnlyTool(toolName: string): boolean {
21 return /^(find|read|list|get|search)/i.test(toolName);
22}
23
24let client: MCPClient | null = null;
25let toolsetsPromise: Promise<Awaited<ReturnType<MCPClient["listToolsets"]>>> | null = null;
26
27export function getPayloadMcpClient(): MCPClient {
28 if (client) return client;
29
30 const apiKey = process.env.MCP_API_KEY;
31 if (!apiKey) {
32 throw new Error(
33 "MCP_API_KEY is not configured. Generate a Payload MCP API key and set it in the environment.",
34 );
35 }
36
37 client = new MCPClient({
38 id: "payload-mcp",
39 servers: {
40 [SERVER_NAME]: {
41 url: mcpUrl(),
42 requestInit: { headers: { Authorization: `Bearer ${apiKey}` } },
43 // Dynamic approval: the MCP server's own annotations win when present,
44 // otherwise fall back to the tool-name heuristic.
45 requireToolApproval: ({ toolName, annotations }) => {
46 if (annotations?.readOnlyHint) return false;
47 if (annotations?.destructiveHint) return true;
48 return !isReadOnlyTool(toolName);
49 },
50 },
51 },
52 });
53
54 // If the server's tool list changes (e.g. an admin toggles a capability),
55 // drop the cache so the next request re-discovers the tools.
56 try {
57 void client.tools
58 .onListChanged(SERVER_NAME, () => { toolsetsPromise = null; })
59 .catch(() => undefined);
60 } catch {
61 // Notification subscription is best-effort.
62 }
63
64 return client;
65}
66
67/** Payload toolsets, cached for the lifetime of the process. */
68export async function getPayloadToolsets(): Promise<
69 Awaited<ReturnType<MCPClient["listToolsets"]>>
70> {
71 if (!toolsetsPromise) {
72 toolsetsPromise = getPayloadMcpClient()
73 .listToolsets()
74 .catch((error) => {
75 // Don't cache failures — allow the next request to retry.
76 toolsetsPromise = null;
77 throw error;
78 });
79 }
80 return toolsetsPromise;
81}

وهنا ثلاثة أمور تحدث معًا:

  • عنوان الاسترجاع المحلي: عندما يعمل الوكيل داخل نفس عملية Next.js التي يعمل فيها خادم Payload MCP، فاتصل بـ http://127.0.0.1:PORT/api/mcp. أما اشتقاق الرابط من request.url فيرسل الطلب عبر نطاقك العام — وعبر أي وكيل عكسي أو شبكة توصيل محتوى أمامه — وهذا يكسر نقل MCP.
  • أدوات مُخزَّنة مؤقتًا: تُنفَّذ listToolsets() مرة واحدة لكل عملية، ويتشارك الطلبات المتزامنة طلب اكتشاف واحد. ولا تُخزَّن حالات الفشل، فيعيد الطلب التالي المحاولة؛ وإذا تغيّرت قائمة أدوات الخادم (مثل تبديل المسؤول لقدرة ما) أفرغ onListChanged المخزَّن المؤقت.
  • موافقات واعية بالتعليقات (annotations): يعطي شرط الموافقة الأولوية لتعليقات خادم MCP — readOnlyHint يعني نفّذها، وdestructiveHint يعني اسأل دائمًا. ولا يعود إلى قاعدة أسماء الأدوات إلا عند غياب التعليقين.

ولأن العميل طويل الأمد لا يوجد ما يجب تفكيكه في كل طلب، فيستطيع معالج المسار إرجاع تدفّق Mastra مباشرةً. لا غلاف ReadableStream، ولا قلق من أن تُغلق after() الاتصال بينما لا يزال الوكيل ينفّذ استدعاءات الأدوات.

4. المصادقة وخيوط كل مسؤول في مكان واحد

كل مسار محادثة يحتاج الأجوبة الثلاثة نفسها: هل هذا مسؤول مُصادَق عليه؟ وما بادئة خيوطه؟ وهل الخيط الذي يسأل عنه يخصّه فعلًا؟ ضعها في وحدة واحدة حتى لا تتباعد القاعدة بين المسارات.

typescript
1// src/lib/chat-auth.ts
2import configPromise from "@payload-config";
3import { hasRole } from "@/access-types";
4import { getPayload } from "payload";
5
6/**
7 * The AI Assistant is admin-only. Every chat route (chat, threads, undo, clear,
8 * discard) authenticates through here, so the rule lives in exactly one place.
9 */
10export const RESOURCE_ID = "cms";
11export type ChatUser = { id: string | number };
12
13export async function authenticateChatAdmin(request: Request): Promise<ChatUser | null> {
14 const payload = await getPayload({ config: configPromise });
15 try {
16 const { user } = await payload.auth({ headers: request.headers });
17 if (user?.collection === "users" && hasRole(user, "admin")) {
18 return { id: user.id };
19 }
20 return null;
21 } catch {
22 return null;
23 }
24}
25
26export function threadPrefix(user: ChatUser): string {
27 return `admin-${user.id}`;
28}
29
30/**
31 * Threads are namespaced per admin: `admin-<id>` (default) or
32 * `admin-<id>-<timestamp>` for explicitly created sessions. A provided id is
33 * only honoured when it belongs to this admin.
34 */
35export function resolveThreadId(user: ChatUser, provided?: string | null): string {
36 const prefix = threadPrefix(user);
37 if (typeof provided === "string" && provided && provided.startsWith(prefix)) {
38 return provided;
39 }
40 return prefix;
41}
42
43export function isOwnThread(user: ChatUser, threadId?: string | null): threadId is string {
44 return (
45 typeof threadId === "string" &&
46 threadId.length > 0 &&
47 threadId.startsWith(threadPrefix(user))
48 );
49}

لاحظ فحص الدور. مفتاح API الذي يستخدمه الوكيل مفتاح بصلاحيات مسؤول، فوجب أن تكون نقطة نهاية المحادثة مقصورة على المسؤولين — وإلا استطاع مدير يملك جلسة صحيحة أن يطلب من الوكيل قراءة مجموعات لا يمكنه فتحها في اللوحة. وتُبَدَّأ معرّفات الخيوط بالبادئة admin-<userId> (أو admin-<userId>-<timestamp> للجلسات المنشأة صراحةً)، ولا يقبل resolveThreadId إلا معرّفًا يبدأ ببادئة المستدعي نفسه.

5. دع المسؤولين يضبطون مزودي النماذج

تثبيت اسم النموذج في الشيفرة يعني إعادة نشر في كل مرة تريد تجربة نموذج أو مزود آخر. بدلاً من ذلك، أضف مجموعة llm-providers إلى Payload: قائمة إعدادات مسبقة (OpenAI وOpenRouter وGroq وOpenCode Go أو مخصص)، ورابط أساس، ومفتاح API مخزّن في قاعدة البيانات بصلاحيات على مستوى الحقل بحيث لا يقرأه إلا المسؤولون، وقائمة معرّفات النماذج في خطتك، ومفتاح تمكين. ويستطيع حقل واجهة صغير «جلب النماذج» أن يتصل بنقطة نهاية /models لدى المزود ويكتب المعرّفات، فلا يكتبها أحد يدويًا.

typescript
1// src/collections/LLMProviders.ts — admins manage models from the admin UI
2export const LLMProviders: CollectionConfig = {
3 slug: "llm-providers",
4 admin: { useAsTitle: "title" },
5 access: { read: isAdmin, create: isAdmin, update: isAdmin, delete: isAdmin },
6 fields: [
7 {
8 name: "preset",
9 type: "select",
10 defaultValue: "custom",
11 options: LLM_PROVIDER_PRESETS.map((p) => ({ label: p.label, value: p.value })),
12 // fills Name / Slug / Base URL client-side (e.g. "OpenCode Go")
13 },
14 { name: "title", type: "text", required: true },
15 // Slug prefixes model ids: "openai" -> openai/gpt-4o
16 { name: "slug", type: "text", required: true, unique: true },
17 { name: "baseUrl", type: "text", required: true }, // https://api.openai.com/v1
18 {
19 name: "apiKey",
20 type: "text",
21 required: true,
22 // Stored in the database, readable only by admins — never by managers,
23 // never by the chat UI.
24 access: { read: isAdminFieldLevel, update: isAdminFieldLevel },
25 },
26 { name: "models", type: "text", hasMany: true }, // the model ids on your plan
27 { name: "enabled", type: "checkbox", defaultValue: true },
28 { name: "fetchModels", type: "ui" }, // "Fetch models" button -> provider /models
29 ],
30};

منطق التوجيه الذي يحوّل معرّف النموذج المختار إلى إعداد نموذج يفهمه Mastra هو الجزء الأهم. معرّف النموذج هو providerSlug/modelId؛ ويحمّل المحلِّل المزود، ويتحقق من النموذج، ثم يعيد الصيغة التي يفهمها Mastra:

typescript
1// src/lib/llm-providers.ts (core)
2export async function resolveModelConfig(
3 payload: Payload,
4 model: string, // "opencode-go/deepseek-v4-pro"
5 options?: { sessionId?: string },
6): Promise<OpenAICompatibleConfig | null> {
7 const { slug, modelId } = splitModel(model); // providerSlug/modelId
8 if (!slug || !modelId) return null;
9
10 const provider = await findEnabledProvider(payload, slug);
11 if (!provider?.baseUrl || !provider?.apiKey) return null;
12 if (provider.models?.length && !provider.models.includes(modelId)) return null;
13
14 const headers = buildProviderHeaders(provider.baseUrl, options?.sessionId);
15 const withHeaders = headers ? { headers } : {};
16
17 // Registry-known providers are resolved by Mastra, which picks the correct
18 // endpoint per model (chat/completions, /responses or /messages) from its
19 // bundled provider registry. Passing `url` would force the plain
20 // OpenAI-compatible chat endpoint, so we deliberately omit it.
21 if (getProviderConfig(slug)) {
22 return { providerId: slug, modelId, apiKey: provider.apiKey, ...withHeaders };
23 }
24
25 // Custom providers fall back to an explicit OpenAI-compatible base URL.
26 return {
27 id: `${slug}/${modelId}`,
28 url: provider.baseUrl.replace(/\/+$/, ""),
29 apiKey: provider.apiKey,
30 ...withHeaders,
31 };
32}

  • المزودون المعروفون لسجل Mastra يُعادون بصيغة { providerId, modelId } وبدون url عن قصد. عندئذٍ يتولى سجل مزودات Mastra المضمّن اختيار نقطة النهاية الصحيحة لكل نموذج — فبعض المزودين يقدّم نماذج مختلفة على chat/completions و/responses و/messages — أما تمرير url فيفرض على الجميع مسار التوافق العادي مع OpenAI.
  • المزودون المخصصون المتوافقون مع OpenAI يعودون إلى { id, url } أي رابط الأساس العادي لـ chat/completions.
  • بعض المزودين يحتاجون أكثر من مفتاح. فمثلاً يرفض OpenCode الطلبات التي لا تحمل ترويسة x-opencode-session ثابتة، ويطلب من العملاء تعريف أنفسهم بـ User-Agent خاص، فيشتقّ المحلِّل الترويسات المناسبة من نطاق رابط الأساس ويمرّر معرّف خيط المحادثة كمعرّف جلسة، فيبقى التوجيه والتخزين المؤقت ثابتين على مدى المحادثة كاملة.

تقرأ واجهة المحادثة المجموعة نفسها عبر إجراء خادم (server action) لبناء منتقي نماذج مجمَّع حسب المزود، وتحفظ اختيار كل مسؤول في مستند المستخدم الخاص به — فتصبح عملية تبديل النموذج قائمة منسدلة، لا عملية نشر.

6. عرّف وكيل Mastra

يُحدَّد نموذج الوكيل لكل طلب من سياق الطلب، ولا يوجد إعداد افتراضي مدمج عن قصد: فإذا عجز المسار عن تحديد النموذج أعاد استجابة 400 بدلاً من الرجوع صامتًا إلى نموذج لم يختره المسؤول.

typescript
1// src/mastra/index.ts
2import { Mastra } from "@mastra/core";
3import { mastraStore } from "./memory";
4import { cmsAgent } from "./agents/cms-agent";
5
6export const mastra = new Mastra({ agents: { cmsAgent }, storage: mastraStore });
7
8// src/mastra/agents/cms-agent.ts
9import { Agent } from "@mastra/core/agent";
10import type { OpenAICompatibleConfig } from "@mastra/core/llm";
11import { memory } from "../memory";
12import { webSearch } from "../tools/webSearch";
13
14export const cmsAgent = new Agent({
15 id: "cms-agent",
16 name: "CMS Assistant",
17 model: ({ requestContext }): string | OpenAICompatibleConfig => {
18 // The route resolves the selected model against the admin-configured LLM
19 // providers and sets it on the request context as an OpenAI-compatible
20 // config ({ providerId, modelId, apiKey } | { id, url, apiKey }).
21 // There is intentionally no built-in default.
22 const cfg = requestContext?.get?.("modelConfig");
23 if (cfg) return cfg as OpenAICompatibleConfig;
24 const selected = requestContext?.get?.("model");
25 return typeof selected === "string" && selected ? selected : "";
26 },
27 memory,
28 tools: { webSearch },
29 defaultOptions: { maxSteps: 12 },
30 instructions: `
31You are the AI assistant for this CMS.
32
33TOOLS
34- findProducts, findOrders, findCustomers: read data. Safe, no approval needed.
35- create*/update* tools: write data. Gated by the platform.
36
37WORKFLOW
381. Reads are safe. Call the find tools directly instead of describing the query.
39 Filters MUST be passed inside \`where\` as a JSON string, e.g.
40 where = '{"status":{"equals":"paid"}}'. Use a small limit (5-10).
412. Writes are gated: every create/update pauses for the user to Approve or
42 Decline in the chat. State briefly what you are about to change and let the
43 approval prompt handle consent. Never try to bypass it.
443. NEVER run the same write tool twice for one request. If a write fails, report
45 the exact error and stop — do not retry automatically.
46
47SIDE EFFECTS ENFORCED BY THE PLATFORM (do not replicate with extra tool calls)
48- Creating an order recalculates the customer balance.
49- Therefore do NOT call updateCustomers after createOrders. One write per intent.
50
51STYLE
52- Be professional, concise and practical. Answer in the language the user writes in.
53- When you are missing a required value, ask one focused question instead of guessing.
54`.trim(),
55});

أمران يجدر التنبيه إليهما. التعليمات هي حواجز الأمان: تصف كل مجموعة، وتصنّف أدوات find كآمنة وأدوات الكتابة كمُقيَّدة، وتحذّر من الآثار الجانبية (هنا: إنشاء الطلب يعيد حساب رصيد العميل تلقائيًا، فلا يحق للوكيل أن «يساعد» باستدعاء أداة تحديث العميل بعدها). كما يحدّ maxSteps حلقة الوكيل كي لا يدور نموذج مرتبك إلى ما لا نهاية.

ويمكنك أيضًا تسجيل أدواتك الخاصة إلى جانب أدوات MCP. ووجود أداة بحث في الويب مفيد، لأن الوكيل لا يعرف سعر الصرف اليوم ولا يستطيع قراءة وثائق المزودين:

typescript
1// src/mastra/tools/webSearch.ts (excerpt)
2export const webSearch = createTool({
3 id: "webSearch",
4 description:
5 "Search the web for current, factual information. Use before answering questions " +
6 "about recent events, technologies or documentation. Falls back to another provider " +
7 "if one is rate-limited. Returns a few concise results (title, URL, short snippet).",
8 requireApproval: false,
9 inputSchema: z.object({
10 query: z.string().min(3).describe("The search query, 3+ characters. Be specific."),
11 maxResults: z.number().min(1).max(5).default(3),
12 }),
13 execute: async ({ query, maxResults }) => {
14 const { provider, results } = await searchWeb(query, maxResults ?? 3);
15 return { provider, query, results };
16 },
17});
18
19// Tavily/Firecrawl snippets are full-page text with lots of markdown noise.
20// Collapse whitespace and truncate so the tool result stays small — large blobs
21// get dropped by some OpenAI-compatible providers and read as "no results".
22function cleanContent(value: string): string {
23 const collapsed = value.replace(/\s+/g, " ").trim();
24 return collapsed.length > 300 ? `${collapsed.slice(0, 300)}…` : collapsed;
25}

وتفصيلان إنتاجيان عن مخرجات الأدوات: اقتطعها (300 حرف لكل مقتطف هنا) وأبقِ شكلها صغيرًا. فالمخرجات الضخمة تُسقَط لدى بعض المزودين المتوافقين مع OpenAI، فيتصرف النموذج عندئذٍ كما لو أن البحث لم يُعِد شيئًا.

7. الذاكرة على نفس قاعدة MongoDB

هنا يصبح كل شيء لطيفًا. يخزّن Payload محتواك في MongoDB عبر mongooseAdapter، ويقبل MongoDBStore من Mastra نفس متغير البيئة DATABASE_URI، فتستقر ذاكرة المحادثات في قاعدة البيانات نفسها — باسم قاعدة منفصل هو mastra افتراضيًا. صفر بنية تحتية إضافية.

typescript
1// src/mastra/memory.ts
2import { Memory } from "@mastra/memory";
3import { MongoDBStore } from "@mastra/mongodb";
4
5export const mastraStore = new MongoDBStore({
6 id: "mastra-storage",
7 uri: process.env.DATABASE_URI || "", // the same MongoDB as Payload
8 dbName: process.env.MASTRA_DB_NAME || "mastra",
9});
10
11export const memory = new Memory({
12 storage: mastraStore,
13 options: {
14 lastMessages: 20, // context window of recent history
15 generateTitle: true, // auto-title each session from the first message
16 },
17});

وgenerateTitle لمسة صغيرة ممتعة: بعد أول تبادل يولّد Mastra عنوانًا للخيط بشكل غير متزامن، فتستعلم الواجهة عنه وتعرضه في قائمة الجلسات.

8. مسار واجهة برمجة المحادثة

يقوم معالج المسار /api/chat بخمسة أمور: مصادقة المسؤول، وتحويل النموذج المطلوب إلى إعداد مزود، وجلب أدوات Payload المخزَّنة مؤقتًا، وبثّ تشغيل الوكيل، وإرجاع ذلك التدفّق كما هو.

typescript
1// src/app/api/chat/route.ts
2export const maxDuration = 60;
3export const runtime = "nodejs";
4export const dynamic = "force-dynamic";
5
6export async function POST(request: Request) {
7 const user = await authenticateChatAdmin(request);
8 if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
9
10 const body = await request.json().catch(() => null);
11 const messages = Array.isArray(body?.messages) ? body.messages : [];
12 if (messages.length === 0) {
13 return NextResponse.json({ error: "No messages provided" }, { status: 400 });
14 }
15
16 const threadId = resolveThreadId(user, body?.data?.threadId);
17 const selectedModel = typeof body?.data?.model === "string" ? body.data.model : "";
18 if (!selectedModel) {
19 return NextResponse.json({ error: "No model selected" }, { status: 400 });
20 }
21
22 const payload = await getPayload({ config: configPromise });
23 const modelConfig = await resolveModelConfig(payload, selectedModel, {
24 sessionId: threadId, // stable per-conversation routing/caching key
25 });
26 if (!modelConfig) {
27 return NextResponse.json(
28 { error: "Selected model is unavailable. Check LLM Providers." },
29 { status: 400 },
30 );
31 }
32
33 let toolsets;
34 try {
35 toolsets = await getPayloadToolsets();
36 } catch (error) {
37 const detail = error instanceof Error ? error.message : "Unknown error";
38 return NextResponse.json({ error: "MCP server unavailable", detail }, { status: 503 });
39 }
40
41 const requestContext = new RequestContext();
42 requestContext.set("modelConfig", modelConfig);
43 requestContext.set("threadId", threadId);
44
45 try {
46 const stream = await handleChatStream({
47 mastra,
48 agentId: "cms-agent",
49 version: "v7",
50 params: {
51 messages,
52 memory: { thread: threadId, resource: RESOURCE_ID },
53 requestContext,
54 abortSignal: request.signal, // Stop really cancels the provider call
55 },
56 defaultOptions: { toolsets, maxSteps: 12 },
57 });
58
59 // The MCP client is long-lived, so there is no connection to tear down:
60 // return the Mastra stream as-is.
61 return createUIMessageStreamResponse({ stream });
62 } catch (error) {
63 const message = error instanceof Error ? error.message : String(error);
64 if (/ECONNRESET|MongoNetworkError|agentic-loop/i.test(message)) {
65 return NextResponse.json(
66 { error: "Storage temporarily unavailable, please retry." },
67 { status: 503 },
68 );
69 }
70 return NextResponse.json({ error: "Chat failed", detail: message }, { status: 500 });
71 }
72}

وهذه تفاصيل تستحق النسخ:

  • أرسل أحدث رسالة فقط. فالذاكرة على الخادم هي مصدر الحقيقة، ولذلك يرسل العميل messages.slice(-1) — آخر رسالة من المستخدم، أو رسالة المساعد التي تحمل رد الموافقة على أداة. أما إرسال السجل كاملًا فيكرّر كل تبادل سابق في سياق النموذج.
  • مرّر abortSignal مرتبطًا بـ request.signal حتى يؤدي زر الإيقاف إلى إلغاء طلب المزود فعليًا، لا إلى إيقاف الواجهة فقط.
  • افصل فشل MCP عن فشل النموذج. إذا تعذّر جلب قائمة الأدوات فأعد 503 مع رسالة «خادم MCP غير متاح» والتفصيل الأساسي — وإلا بدت كل الأعراض وكأن «المساعد معطّل».
  • تعامل مع MongoNetworkError وECONNRESET كاستجابة 503 واطلب من المسؤول إعادة المحاولة؛ فالتعثّر العابر في التخزين لا يجب أن يبدو خطأً في المحادثة.

أما معالج GET فيعيد سجل الخيط. ورسائل Mastra ليست بالشكل الذي تتوقعه رسائل الواجهة، فحوّلها بـ toAISdkMessages({ version: "v7" }) — وتذكّر أن UIMessage لا يحمل createdAt، فأظهر الطابع الزمني عبر بيانات الرسالة الوصفية إن أردت عرض الأوقات في الواجهة.

typescript
1// src/app/api/chat/route.ts (GET: history for a thread)
2export async function GET(request: Request) {
3 const user = await authenticateChatAdmin(request);
4 if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
5
6 const { searchParams } = new URL(request.url);
7 const threadId = resolveThreadId(user, searchParams.get("threadId"));
8
9 const agent = mastra.getAgentById("cms-agent");
10 const memory = await agent.getMemory();
11
12 let messages = [];
13 try {
14 const response = await memory?.recall({ threadId, resourceId: RESOURCE_ID });
15 messages = response?.messages ?? [];
16 } catch {
17 // no history yet
18 }
19
20 const uiMessages = toAISdkMessages(messages, { version: "v7" });
21
22 // UIMessage carries no createdAt, so surface it through metadata for the UI.
23 const createdAtById = new Map(messages.map((m) => [String(m.id), m.createdAt]));
24 const enriched = uiMessages.map((m) => {
25 const ts = createdAtById.get(String(m.id));
26 if (!ts) return m;
27 const meta = (m as { metadata?: unknown }).metadata;
28 return {
29 ...m,
30 metadata: { ...(typeof meta === "object" && meta ? meta : {}), createdAt: ts },
31 };
32 });
33
34 return NextResponse.json(enriched);
35}

9. واجهة الجلسات

مسار مصاحب على /api/chat/threads يدير الجلسات. دع طبقة التخزين تتولى الترقيم والترتيب، وطبّق بادئة كل مسؤول على الصفحة المُعادة — فسرد كل الخيوط ثم تصفيتها في الذاكرة يبطؤ مع كل محادثة جديدة:

typescript
1// src/app/api/chat/threads/route.ts
2export async function GET(request: Request) {
3 const user = await authenticateChatAdmin(request);
4 if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
5
6 const { searchParams } = new URL(request.url);
7 const page = Math.max(0, parseInt(searchParams.get("page") ?? "0", 10) || 0);
8 const perPage = Math.min(50, Math.max(1, parseInt(searchParams.get("perPage") ?? "20", 10) || 20));
9
10 const memory = await (await mastra.getAgentById("cms-agent")).getMemory();
11 if (!memory) return NextResponse.json({ threads: [], total: 0, page, hasMore: false });
12
13 try {
14 // Storage filters by resource server-side (bounded result set) and sorts;
15 // the per-admin `admin-<id>` prefix is applied to the page we got back.
16 const result = await memory.listThreads({
17 page,
18 perPage,
19 orderBy: { field: "updatedAt", direction: "DESC" },
20 filter: { resourceId: RESOURCE_ID },
21 });
22
23 const prefix = threadPrefix(user);
24 const own = (result.threads ?? []).filter((t) => String(t.id).startsWith(prefix));
25
26 return NextResponse.json({
27 threads: own.map((t) => ({
28 id: t.id,
29 title: t.title || (t.metadata as { title?: string })?.title || null,
30 createdAt: t.createdAt,
31 updatedAt: t.updatedAt,
32 })),
33 total: result.total,
34 page: result.page,
35 hasMore: result.hasMore,
36 });
37 } catch {
38 return NextResponse.json({ threads: [], total: 0, page, hasMore: false });
39 }
40}
41
42export async function POST(request: Request) {
43 const user = await authenticateChatAdmin(request);
44 if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
45 return NextResponse.json({ threadId: `admin-${user.id}-${Date.now()}` });
46}
47
48export async function DELETE(request: Request) {
49 const user = await authenticateChatAdmin(request);
50 if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
51
52 const threadId = new URL(request.url).searchParams.get("threadId");
53 if (!isOwnThread(user, threadId)) {
54 return NextResponse.json({ error: "Invalid or unauthorized thread" }, { status: 400 });
55 }
56
57 const memory = await (await mastra.getAgentById("cms-agent")).getMemory();
58 await memory!.deleteThread(threadId);
59 return NextResponse.json({ deleted: true });
60}

ولأن فحص البادئة موجود في isOwnThread()، يرفض كل مسار تعديلي (الحذف والتراجع والمسح والتخلّص) أي معرّف خيط لا يخصّ المستدعي. فلا يمكن لأحد أن يقرأ محادثة مسؤول آخر أو يمحوها رغم أن كل الخيوط تتشارك معرّف مورد واحد.

10. الموافقات البشرية بالطريقة الأصلية

كانت الموافقات تعني مسارًا منفصلًا يستعلم عن التشغيلات المعلّقة ثم يستأنفها، ويفشل بـ 404 إذا انتهت صلاحية التشغيل. وإذا كانت نسخ Mastra وAI SDK لديك تدعم ذلك، فالتدفّق الأصلي يحذف هذا المسار تمامًا: يوسم requireToolApproval استدعاء الأداة، ويصدر التدفّق جزء أداة بحالة approval-requested، فتردّ الواجهة بـ addToolApprovalResponse.

typescript
1// src/components/admin/ChatUI.tsx (approvals, native AI SDK v7)
2const { messages, sendMessage, status, addToolApprovalResponse } = useChat({
3 transport,
4 // Re-send automatically as soon as the last approval is answered.
5 sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses,
6});
7
8const pendingApproval = useMemo(() => {
9 for (const m of [...messages].reverse()) {
10 for (const p of [...(m.parts ?? [])].reverse()) {
11 if (!isToolUIPart(p) && !isDynamicToolUIPart(p)) continue;
12 if (p.state !== "approval-requested") continue;
13 if (p.approval?.isAutomatic) continue; // auto-approved tools never block
14 return {
15 toolName: getToolName(p).replace(/^payload_/, ""),
16 approvalId: p.approval.id,
17 input: p.input as Record<string, unknown> | undefined,
18 };
19 }
20 }
21 return null;
22}, [messages]);
23
24const respondApproval = (approved: boolean) => {
25 if (!pendingApproval) return;
26 addToolApprovalResponse({ id: pendingApproval.approvalId, approved });
27};
28
29// ...and the banner that renders while a write waits for a human:
30{pendingApproval && (
31 <div role="status">
32 <div>Tool requires approval</div>
33 <div>{pendingApproval.toolName} with arguments:</div>
34 <pre>{JSON.stringify(pendingApproval.input ?? {}, null, 2)}</pre>
35 <button onClick={() => respondApproval(true)}>Approve</button>
36 <button onClick={() => respondApproval(false)}>Decline</button>
37 </div>
38)}

والأجزاء التي تجعله يعمل:

  • الموافقة جزء من قائمة الرسائل، فتبقى بعد تحديث الصفحة — ولا يحتاج الطلب التالي إلى حمل السجل كله، بل رسالة المساعد التي تحمل الرد فقط.
  • يجعل sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithApprovalResponses العميل يعيد الإرسال فور الإجابة على آخر موافقة، فيستأنف التشغيل دون نقرة إضافية.
  • تجاهل الموافقات التلقائية في الشريط (approval.isAutomatic)؛ فالأداة الموافق عليها تلقائيًا لا ينبغي أن تحجب مربع الكتابة بسؤال.
  • تتحول حالة الأداة إلى وسم واضح: input-streaming وinput-available ← «قيد التنفيذ»، وapproval-requested ← «بانتظار الموافقة»، وapproval-responded ← «تمت الموافقة» أو «رُفض»، وoutput-available ← «اكتمل»، وoutput-error ← «خطأ».

وهذا ما يجعل الوكيل الموجَّه للمسؤول موثوقًا: يستطيع النموذج تصفّح أي شيء، لكنه لا يستطيع تغيير أي شيء دون قرار بشري صريح داخل الحلقة — وتكون وسائط الأداة الدقيقة معروضة على الشاشة لحظة القرار.

11. مسارات أدوات الجلسة

ثلاثة مسارات صغيرة تُكمل التجربة. يحذف /api/chat/undo آخر رسالة من المستخدم وكل ما بعدها، فيعيد /undo ذلك النص إلى مربع الكتابة. ويمحو /api/chat/clear خيطًا كاملًا. ويزيل /api/chat/discard-last رسائل المساعد الجزئية المتبقية عند الضغط على «إيقاف» — ويرسل العميل المعرّفات المطلوب حذفها بدقة، فلا يحتاج الخادم إلى التخمين:

typescript
1// src/app/api/chat/discard-last/route.ts
2export async function POST(request: Request) {
3 const user = await authenticateChatAdmin(request);
4 if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
5
6 const body = await request.json().catch(() => ({}));
7 if (!isOwnThread(user, body.threadId)) {
8 return NextResponse.json({ error: "Invalid or unauthorized thread" }, { status: 400 });
9 }
10
11 const memory = await (await mastra.getAgentById("cms-agent")).getMemory();
12
13 // Mastra's MongoDB snapshot cleanup can throw ECONNRESET on transient network
14 // hiccups. The messages are already marked terminal, so a best-effort delete
15 // is fine — don't surface it as an error.
16 async function deleteMessagesSafe(mem, ids: string[]) {
17 try {
18 await mem.deleteMessages(ids);
19 } catch (e) {
20 const msg = e instanceof Error ? e.message : String(e);
21 if (!/ECONNRESET|MongoNetworkError/i.test(msg)) throw e;
22 }
23 }
24
25 // The client sends the exact trailing assistant ids it wants removed, so we
26 // never have to guess and never touch older history.
27 if (Array.isArray(body.ids) && body.ids.length > 0) {
28 const ids = body.ids.map(String).filter(Boolean).slice(0, 10);
29 await deleteMessagesSafe(memory, ids);
30 return NextResponse.json({ ok: true, deleted: true, count: ids.length });
31 }
32
33 // Defensive fallback (no ids): delete only the most recent message if it is
34 // an assistant reply. Never touches anything older.
35 const { messages } = await memory.recall({ threadId: body.threadId, resourceId: RESOURCE_ID });
36 const last = messages[messages.length - 1];
37 if (last?.role === "assistant") {
38 await deleteMessagesSafe(memory, [String(last.id)]);
39 return NextResponse.json({ ok: true, deleted: true, count: 1 });
40 }
41 return NextResponse.json({ ok: true, deleted: false });
42}

لاحظ deleteMessagesSafe: قد ترمي عملية تنظيف لقطات MongoDB خطأ ECONNRESET عند تعثّر الشبكة. والرسائل نهائية أصلاً، فالحذف بأفضل جهد هو السلوك الصحيح — وابتلاع هذا الصنف من الأخطاء يمنع ظهور فشل مخيف من عملية نجحت فعليًا.

12. الواجهة: عرض في لوحة تحكم Payload

سجّل مكوّن المحادثة كعرض مخصص في اللوحة ليظهر على /admin/chat:

typescript
1// payload.config.ts — register the chat as an admin view
2admin: {
3 components: {
4 views: {
5 aiAssistant: {
6 Component: "/components/admin/ChatUI#default",
7 path: "/chat",
8 },
9 },
10 },
11}

المكوّن مكوّن عميل يستخدم خطافات Payload ومتغيرات CSS الخاصة به. يمنحك useConfig() الوصول إلى config.routes.admin لرابط «العودة إلى اللوحة»، ويمنحك useAuth() المستخدم الحالي ليرفض العرض غير المسؤولين، أما التنسيق بـ var(--theme-bg) وvar(--theme-elevation-100) وvar(--theme-text) وvar(--style-radius-m) وvar(--font-mono) فيعني أن الوضع الفاتح/الداكن وأي تغيير مستقبلي في تصميم Payload يأتي مجانًا.

typescript
1// src/components/admin/ChatUI.tsx
2"use client";
3
4import { useChat } from "@ai-sdk/react";
5import { useAuth, useConfig } from "@payloadcms/ui";
6import { DefaultChatTransport } from "ai";
7
8// Payload's own design tokens: themes, light/dark and future restyles for free.
9const v = {
10 bg: "var(--theme-bg)",
11 text: "var(--theme-text)",
12 e100: "var(--theme-elevation-100)",
13 radiusM: "var(--style-radius-m)",
14 mono: "var(--font-mono)",
15 // ...
16};
17
18export default function ChatUI() {
19 const { config } = useConfig();
20 const { user } = useAuth();
21 const adminRoute = config.routes.admin;
22
23 // Built once: prepareSendMessagesRequest reads the current model/thread from
24 // refs at send time, so changing them never requires a new transport.
25 const transport = useMemo(
26 () =>
27 new DefaultChatTransport({
28 api: "/api/chat",
29 prepareSendMessagesRequest: ({ messages }) => ({
30 body: {
31 // Server-side memory is the source of truth: send only the newest
32 // message (a user turn, or the assistant message carrying an
33 // approval response). Sending full history duplicates context.
34 messages: messages.slice(-1),
35 data: { model: modelRef.current, threadId: threadRef.current },
36 },
37 }),
38 }),
39 [],
40 );
41
42 if (user && !user.roles?.includes("admin")) {
43 return <div>The AI Assistant is available to administrators only.</div>;
44 }
45
46 // ... sessions sidebar, messages, tool cards, approval banner, composer
47}

وما تبقّى عمل معتاد في أي تطبيق محادثة، لكن هذه هي الأجزاء التي يلاحظها المسؤولون فعلًا:

  • شريط جانبي للجلسات بفروق زمنية نسبية، وترقيم «تحميل المزيد»، وتأكيد حذف في المكان
  • بطاقات أدوات لكل استدعاء يجريه الوكيل، مطويّة افتراضيًا، تعرض JSON المدخلات والمخرجات ووسم الحالة
  • شريط موافقة يعرض الوسائط الخام مع زرّي «موافقة» و«رفض»
  • سطر حالة حيّ — «يفكر…» أو «يستخدم findOrders…» — داخل رسالة المساعد أثناء بثّ التشغيل
  • أوامر مائلة مع قائمة إكمال تلقائي: /undo و/new و/clear و/help
  • منتقي نماذج مجمَّع يتذكّر اختيار كل مسؤول، وتنبيه واضح مع رابط إلى مزودي النماذج عند عدم تهيئة أي نموذج
  • dir="auto" على الرسائل ومربع كتابة يراعي الاتجاه من اليمين إلى اليسار، فتظهر المحادثة العربية صحيحة

لماذا هذا الإعداد ممتاز

  • الواجهة عرض في لوحة تحكم Payload، فترث ثيم اللوحة وتنقّلها ومصادقتها وتخطيطها. والتنسيق بمتغيرات CSS الخاصة بـ Payload يعني أن الوضع الفاتح/الداكن وأي تغيير تصميمي مستقبلي يأتي دون جهد. لا واجهة أمامية منفصلة تبنيها أو تنشرها.
  • قاعدة MongoDB واحدة لكل شيء. يشترك mongooseAdapter من Payload وMongoDBStore من Mastra في نفس DATABASE_URI، فيسكن محتواك وذاكرة محادثاتك في قاعدة بيانات واحدة — نسخ احتياطي أسهل، ونشر أسهل، ولا خدمة إضافية تشغّلها.
  • لا تسجيل دخول ثانٍ، والعزل بادئة واحدة: admin-<userId>. ولأن المساعد للمسؤولين فقط، لا يصل المديرون ولا العملاء إلى المحادثات ولا إلى أدوات MCP إطلاقًا.
  • آمن افتراضيًا: تمرّ القراءات تلقائيًا، وتُعلَّق كل عمليات الإنشاء والتحديث وتُعرض للإنسان للموافقة مع وسائط ظاهرة بالكامل.
  • النماذج إعداد لا شيفرة: أضف مزودًا متوافقًا مع OpenAI من لوحة التحكم (بمفتاح محمي على مستوى الحقل)، واجلب قائمة نماذجه، فيظهر في المنتقي مباشرة؛ ويقرأ الوكيل إعداد كل طلب من سياق الطلب.
  • اقتصادي عند التوسع: اتصال MCP واحد لكل عملية على الخادم بدل اتصال لكل رسالة، وقائمة أدوات مخزَّنة، وخيوط مرقّمة على الخادم.

الخلاصة

أصبح لديك الآن مساعد ذكاء اصطناعي يسكن داخل لوحة التحكم، ويفهم نموذج بياناتك عبر MCP، ويتذكّر المحادثات لكل مسؤول، ولا يكتب أبدًا دون سؤال. ومن هنا فالخطوات الطبيعية التالية: إضافة مجموعات أو متغيرات عامة أخرى إلى إضافة MCP، أو كشف عروض تقارير للقراءة فقط، أو منح الوكيل أداة أخرى داخل العملية (بحث أو طلبات HTTP أو توليد ملفات)، أو وصله بمخزن RAG ليجيب عن أسئلتك حول وثائقك أيضًا. أما البنية التحتية — عميل MCP طويل الأمد، وتحويل النماذج لكل طلب، والذاكرة، والجلسات، والموافقات الأصلية، والواجهة المنسّقة — فتبقى كما هي تمامًا.

مشاركة:
Mouktar Aden

الكاتب Mouktar Aden