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
This commit is contained in:
parent
79d3c0a0b3
commit
e57f38cd8f
3 changed files with 253 additions and 11 deletions
|
|
@ -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
|
PAYLOAD_SECRET=b5ac11acc77ea8427d10fcd0
|
||||||
# Added by Payload
|
# Added by Payload
|
||||||
|
|
||||||
|
|
@ -21,3 +21,5 @@ DISCORD_ANNOUNCE_CHANNEL_ID=1136692636481237173
|
||||||
DISCORD_STAFF_ROLE_IDS=851910807277404187,851910807277404188,851910807277404189,851910807289462814
|
DISCORD_STAFF_ROLE_IDS=851910807277404187,851910807277404188,851910807277404189,851910807289462814
|
||||||
DISCORD_ATTENDANCE_POLL_MS=5000
|
DISCORD_ATTENDANCE_POLL_MS=5000
|
||||||
DISCORD_NOTIFICATION_POLL_MS=5000
|
DISCORD_NOTIFICATION_POLL_MS=5000
|
||||||
|
|
||||||
|
UNSLOTH_API_KEY=sk-unsloth-12a9650c5c90a5b544bfd45786be0516
|
||||||
|
|
|
||||||
|
|
@ -17,13 +17,29 @@ const nextConfig = {
|
||||||
// tree under .next/standalone/WebstormProjects/polaris-task-force/, which
|
// tree under .next/standalone/WebstormProjects/polaris-task-force/, which
|
||||||
// breaks the Dockerfile's `COPY .../standalone ./` + `CMD ["node","server.js"]`).
|
// breaks the Dockerfile's `COPY .../standalone ./` + `CMD ["node","server.js"]`).
|
||||||
outputFileTracingRoot: path.resolve(path.dirname(fileURLToPath(import.meta.url))),
|
outputFileTracingRoot: path.resolve(path.dirname(fileURLToPath(import.meta.url))),
|
||||||
webpack: (webpackConfig) => {
|
webpack: (webpackConfig, { isServer }) => {
|
||||||
webpackConfig.resolve.extensionAlias = {
|
webpackConfig.resolve.extensionAlias = {
|
||||||
".cjs": [".cts", ".cjs"],
|
".cjs": [".cts", ".cjs"],
|
||||||
".js": [".ts", ".tsx", ".js", ".jsx"],
|
".js": [".ts", ".tsx", ".js", ".jsx"],
|
||||||
".mjs": [".mts", ".mjs"],
|
".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;
|
return webpackConfig;
|
||||||
},
|
},
|
||||||
transpilePackages: [
|
transpilePackages: [
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// storage-adapter-import-placeholder
|
// storage-adapter-import-placeholder
|
||||||
import { postgresAdapter } from "@payloadcms/db-postgres";
|
import { postgresAdapter } from "@payloadcms/db-postgres";
|
||||||
import { lexicalEditor } from "@payloadcms/richtext-lexical";
|
import { HeadingFeature, lexicalEditor } from "@payloadcms/richtext-lexical";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { buildConfig } from "payload";
|
import { buildConfig } from "payload";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
|
|
@ -47,9 +47,89 @@ import { migrations } from "./migrations";
|
||||||
import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
|
import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
|
||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
import { Shims } from "@/collections/Shims";
|
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<string, unknown> {
|
||||||
|
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<string> {
|
||||||
|
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 filename = fileURLToPath(import.meta.url);
|
||||||
const dirname = path.dirname(filename);
|
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({
|
export default buildConfig({
|
||||||
// serverURL: "http://localhost:3000/",
|
// serverURL: "http://localhost:3000/",
|
||||||
|
|
@ -60,15 +140,15 @@ export default buildConfig({
|
||||||
},
|
},
|
||||||
meta: {
|
meta: {
|
||||||
titleSuffix: "- Polaris Task Force",
|
titleSuffix: "- Polaris Task Force",
|
||||||
applicationName: "Polaris Task Force",
|
applicationName: META_TITLE,
|
||||||
abstract: "The Polaris Task Force official website.",
|
abstract: META_DESCRIPTION,
|
||||||
keywords: "Polaris Task Force, Polaris, Task Force, Polaris Task Force official website",
|
keywords: "Polaris Task Force, Polaris, Task Force, Polaris Task Force official website",
|
||||||
description: "The Polaris Task Force official website.",
|
description: META_DESCRIPTION,
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "Polaris Task Force",
|
title: META_TITLE,
|
||||||
description: "The Polaris Task Force official website.",
|
description: META_DESCRIPTION,
|
||||||
url: "https://polaris-task-force.com",
|
url: "https://polaris-task-force.com",
|
||||||
siteName: "Polaris Task Force",
|
siteName: META_TITLE,
|
||||||
images: [
|
images: [
|
||||||
{
|
{
|
||||||
url: "/images/icon-favicon.png",
|
url: "/images/icon-favicon.png",
|
||||||
|
|
@ -156,7 +236,11 @@ export default buildConfig({
|
||||||
MissionFiles,
|
MissionFiles,
|
||||||
ModLists,
|
ModLists,
|
||||||
],
|
],
|
||||||
editor: lexicalEditor(),
|
editor: lexicalEditor({
|
||||||
|
features: ({ rootFeatures }) => {
|
||||||
|
return [HeadingFeature(), PayloadAiPluginLexicalEditorFeature()];
|
||||||
|
},
|
||||||
|
}),
|
||||||
secret: process.env.PAYLOAD_SECRET || "",
|
secret: process.env.PAYLOAD_SECRET || "",
|
||||||
typescript: {
|
typescript: {
|
||||||
outputFile: path.resolve(dirname, "payload-types.ts"),
|
outputFile: path.resolve(dirname, "payload-types.ts"),
|
||||||
|
|
@ -186,6 +270,146 @@ export default buildConfig({
|
||||||
plugins: [
|
plugins: [
|
||||||
// payloadCloudPlugin(),
|
// payloadCloudPlugin(),
|
||||||
// storage-adapter-placeholder
|
// 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<string, unknown>;
|
||||||
|
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({
|
email: nodemailerAdapter({
|
||||||
defaultFromAddress: process.env.EMAIL_FROM_ADDRESS || "",
|
defaultFromAddress: process.env.EMAIL_FROM_ADDRESS || "",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue