From e57f38cd8f5fc3e36bc797b7647cc1447fc3f925 Mon Sep 17 00:00:00 2001 From: Z8MB1E Date: Mon, 17 Aug 2026 22:18:23 -0400 Subject: [PATCH] feat(config): switch AI provider from Ollama to Unsloth with streaming support - Add Unsloth OpenAI-compatible provider with @ai-sdk/openai - Rewrite AI generation handlers with Vercel AI SDK streaming - Add plainTextToLexicalJson helper for richText field generation - Configure HeadingFeature and PayloadAiPluginLexicalEditorFeature - Stub @payloadcms/richtext-lexical server entry in client bundle - Extract meta constants for site title and description - Add UNSLOTH_API_KEY to .env.development --- .env.development | 6 +- next.config.mjs | 18 +++- src/payload.config.ts | 240 ++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 253 insertions(+), 11 deletions(-) diff --git a/.env.development b/.env.development index d78d3c1..a43b003 100644 --- a/.env.development +++ b/.env.development @@ -1,4 +1,4 @@ -#DATABASE_URI=postgres://ptfapp:polaristaskforcedb@jmf-usrv-2404:2552/ptf_app_prd +DATABASE_URI=postgres://ptfapp:polaristaskforcedb@jmf-usrv-2404:2552/ptf_app_dev PAYLOAD_SECRET=b5ac11acc77ea8427d10fcd0 # Added by Payload @@ -20,4 +20,6 @@ DISCORD_OPS_CHANNEL_ID=1136692636481237173 DISCORD_ANNOUNCE_CHANNEL_ID=1136692636481237173 DISCORD_STAFF_ROLE_IDS=851910807277404187,851910807277404188,851910807277404189,851910807289462814 DISCORD_ATTENDANCE_POLL_MS=5000 -DISCORD_NOTIFICATION_POLL_MS=5000 \ No newline at end of file +DISCORD_NOTIFICATION_POLL_MS=5000 + +UNSLOTH_API_KEY=sk-unsloth-12a9650c5c90a5b544bfd45786be0516 diff --git a/next.config.mjs b/next.config.mjs index a53a603..bca6cbe 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -17,13 +17,29 @@ const nextConfig = { // tree under .next/standalone/WebstormProjects/polaris-task-force/, which // breaks the Dockerfile's `COPY .../standalone ./` + `CMD ["node","server.js"]`). outputFileTracingRoot: path.resolve(path.dirname(fileURLToPath(import.meta.url))), - webpack: (webpackConfig) => { + webpack: (webpackConfig, { isServer }) => { webpackConfig.resolve.extensionAlias = { ".cjs": [".cts", ".cjs"], ".js": [".ts", ".tsx", ".js", ".jsx"], ".mjs": [".mts", ".mjs"], }; + // @ai-stack/payloadcms client components (Compose -> setSafeLexicalState -> + // lexicalToHTML) import the *server* entry of @payloadcms/richtext-lexical, which + // transitively pulls in `payload` and all Node built-ins (node:fs, node:crypto, + // child_process, worker_threads, ...). The client only needs two pure string + // constants (BLOCK_PLACEHOLDER_PREFIX/SUFFIX) from lexicalToHTML; the server-only + // convertLexicalToHTML is never invoked client-side. Stub the bare server entry in + // the client bundle so the transitive import chain breaks. The `$` makes it an + // exact-match alias, leaving the legitimate `@payloadcms/richtext-lexical/client` + // subpath and the server bundle unaffected. + if (!isServer) { + webpackConfig.resolve.alias = { + ...webpackConfig.resolve.alias, + "@payloadcms/richtext-lexical$": false, + }; + } + return webpackConfig; }, transpilePackages: [ diff --git a/src/payload.config.ts b/src/payload.config.ts index c4bfc09..0570963 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -1,6 +1,6 @@ // storage-adapter-import-placeholder import { postgresAdapter } from "@payloadcms/db-postgres"; -import { lexicalEditor } from "@payloadcms/richtext-lexical"; +import { HeadingFeature, lexicalEditor } from "@payloadcms/richtext-lexical"; import path from "path"; import { buildConfig } from "payload"; import { fileURLToPath } from "url"; @@ -47,9 +47,89 @@ import { migrations } from "./migrations"; import { nodemailerAdapter } from "@payloadcms/email-nodemailer"; import nodemailer from "nodemailer"; import { Shims } from "@/collections/Shims"; +import { payloadAiPlugin, PayloadAiPluginLexicalEditorFeature } from "@ai-stack/payloadcms"; +import { createOpenAI } from "@ai-sdk/openai"; +import { createTextStreamResponse, jsonSchema, streamObject } from "ai"; + +const unslothProvider = createOpenAI({ + apiKey: process.env.UNSLOTH_API_KEY, + baseURL: process.env.UNSLOTH_URL || "https://uns.onyxsimple.com/v1", +}); + +const DEFAULT_MODEL = "unsloth/gemma-4-E4B-it-qat-GGUF"; + +function plainTextToLexicalJson(text: string): Record { + const paragraphs = text.split(/\n\n+/).filter((p) => p.trim()); + + const children = paragraphs.map((para) => ({ + type: "paragraph", + children: [ + { + type: "text", + text: para.trim(), + format: 0, + style: "", + mode: "normal", + detail: 0, + direction: "ltr", + indent: 0, + version: 1, + }, + ], + direction: "ltr", + format: "start", + indent: 0, + textFormat: 0, + textStyle: "", + version: 1, + })); + + return { + root: { + type: "root", + children, + direction: "ltr", + format: "", + indent: 0, + version: 1, + }, + }; +} + +async function generatePlainText( + prompt: string, + system: string, + model: string, +): Promise { + const baseUrl = process.env.UNSLOTH_URL || "https://uns.onyxsimple.com/v1"; + const response = await fetch(`${baseUrl}/chat/completions`, { + method: "POST", + headers: { + Authorization: "Bearer " + process.env.UNSLOTH_API_KEY, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + model, + messages: [ + { role: "system", content: system || "You are a helpful assistant." }, + { role: "user", content: prompt }, + ], + stream: false, + }), + }); + + if (!response.ok) { + throw new Error(`Unsloth API error: ${response.statusText}`); + } + + const result = await response.json(); + return result.choices[0].message.content; +} const filename = fileURLToPath(import.meta.url); const dirname = path.dirname(filename); +const META_TITLE = "Polaris Task Force"; +const META_DESCRIPTION = "A gamified platform for Polaris Task Force, an Arma 3 unit."; export default buildConfig({ // serverURL: "http://localhost:3000/", @@ -60,15 +140,15 @@ export default buildConfig({ }, meta: { titleSuffix: "- Polaris Task Force", - applicationName: "Polaris Task Force", - abstract: "The Polaris Task Force official website.", + applicationName: META_TITLE, + abstract: META_DESCRIPTION, keywords: "Polaris Task Force, Polaris, Task Force, Polaris Task Force official website", - description: "The Polaris Task Force official website.", + description: META_DESCRIPTION, openGraph: { - title: "Polaris Task Force", - description: "The Polaris Task Force official website.", + title: META_TITLE, + description: META_DESCRIPTION, url: "https://polaris-task-force.com", - siteName: "Polaris Task Force", + siteName: META_TITLE, images: [ { url: "/images/icon-favicon.png", @@ -156,7 +236,11 @@ export default buildConfig({ MissionFiles, ModLists, ], - editor: lexicalEditor(), + editor: lexicalEditor({ + features: ({ rootFeatures }) => { + return [HeadingFeature(), PayloadAiPluginLexicalEditorFeature()]; + }, + }), secret: process.env.PAYLOAD_SECRET || "", typescript: { outputFile: path.resolve(dirname, "payload-types.ts"), @@ -186,6 +270,146 @@ export default buildConfig({ plugins: [ // payloadCloudPlugin(), // storage-adapter-placeholder + payloadAiPlugin({ + collections: { + [GameHardResources.slug]: true, + [GameStructures.slug]: true, + [GameVehicles.slug]: true, + [GameNpcs.slug]: true, + [GameEventLogs.slug]: true, + + // Users + [Users.slug]: true, + [Ranks.slug]: true, + [Profiles.slug]: true, + [Awards.slug]: true, + [Qualifications.slug]: true, + [Assignments.slug]: true, + [Experience.slug]: true, + [UserNotifications.slug]: true, + + // Intel + [Missions.slug]: true, + [MissionAttendances.slug]: true, + [Campaigns.slug]: true, + [Factions.slug]: true, + [Technologies.slug]: true, + + // Logistics + [Assets.slug]: true, + [Resources.slug]: true, + [Vehicles.slug]: true, + [Structures.slug]: true, + [Shipments.slug]: true, + + // Banking + [BankAccounts.slug]: true, + [BankTransactions.slug]: true, + [LedgerEntries.slug]: true, + + // Locker + [LockerStorages.slug]: true, + [Loadouts.slug]: true, + + // Market + [MarketListings.slug]: true, + [MarketNegotiations.slug]: true, + + // Helpdesk + [Tickets.slug]: true, + }, + debugging: true, + generationModels: [ + { + id: "unsloth-text", + name: "Unsloth", + fields: ["text", "textarea", "richText"], + handler: async ( + prompt: string, + options: { + locale: string; + model: string; + system: string; + schema: Record; + maxTokens?: number; + temperature?: number; + }, + ) => { + const model = options.model || DEFAULT_MODEL; + + if (options.schema?.definitions) { + const text = await generatePlainText(prompt, options.system, model); + const lexicalJson = plainTextToLexicalJson(text); + const ts = new ReadableStream({ + start(controller) { + controller.enqueue(JSON.stringify(lexicalJson)); + controller.close(); + }, + }); + return createTextStreamResponse({ textStream: ts }); + } + + const streamResult = streamObject({ + maxOutputTokens: options.maxTokens || 5000, + model: unslothProvider(model), + onError: (error) => { + console.error("Unsloth generateObject: ", error); + }, + prompt, + schema: jsonSchema(options.schema), + system: options.system, + temperature: options.temperature ?? 0.7, + providerOptions: { + openai: { + strictJsonSchema: true, + structuredOutputs: true, + }, + }, + }); + + const textStream = new ReadableStream({ + async start(controller) { + try { + for await (const chunk of streamResult.textStream) { + controller.enqueue(chunk); + } + if ((await streamResult.finishReason) === "length") { + controller.enqueue("\u0000"); + } + controller.close(); + } catch (error) { + controller.error(error); + } + }, + }); + + return createTextStreamResponse({ textStream }); + }, + generateText: async (prompt: string, system: string) => { + return generatePlainText(prompt, system, DEFAULT_MODEL); + }, + output: "text", + settings: { + name: "unsloth-text-settings", + type: "group", + admin: { + condition(data) { + return data["model-id"] === "unsloth-text"; + }, + }, + fields: [ + { + name: "model", + type: "text", + defaultValue: DEFAULT_MODEL, + label: "Model", + }, + ], + label: "Unsloth Settings", + }, + }, + ], + }), ], email: nodemailerAdapter({ defaultFromAddress: process.env.EMAIL_FROM_ADDRESS || "",