Add a custom admin view over the technologies collection so staff can model prerequisites as a left-to-right tree instead of a flat list. - React Flow v12 canvas with category bands, OR-prerequisite edges, a lite side-panel editor, and a quick-create dialog. - Propagating layout: a technology with prerequisites lands one column right of its parents at their average row; uncategorized roots fill a catalog grid keyed by each tech's rank among all uncategorized technologies, so promoting one into the tree leaves an empty slot instead of re-shuffling the grid. - Stability contract: connecting or removing edges never moves a card (the connected node is pinned to its current spot), and a "Save positions" control commits every on-screen node so the arrangement survives reload; "Auto-arrange" clears manual positions and restores the ordered layout. - Progression-path edge colors and per-source lanes keep overlapping edges readable. Wire the view and its nav entry into payload.config.ts and add dark React Flow / scrollbar styling.
686 lines
23 KiB
TypeScript
686 lines
23 KiB
TypeScript
import { migrations } from "./migrations";
|
|
import { postgresAdapter } from "@payloadcms/db-postgres";
|
|
import { FixedToolbarFeature, lexicalEditor } from "@payloadcms/richtext-lexical";
|
|
import { nodemailerAdapter } from "@payloadcms/email-nodemailer";
|
|
import path from "path";
|
|
import { buildConfig } from "payload";
|
|
import { fileURLToPath } from "url";
|
|
import sharp from "sharp";
|
|
import nodemailer from "nodemailer";
|
|
|
|
import { Users } from "@/collections/users/Users";
|
|
import { Roles } from "@/collections/users/Roles";
|
|
import { Awards } from "@/collections/users/Awards";
|
|
import { Media } from "@/collections/Media";
|
|
import { Ranks } from "@/collections/users/Ranks";
|
|
import { Profiles } from "@/collections/users/Profiles";
|
|
import { Missions } from "@/collections/intelligence/Missions";
|
|
import { MissionAttendances } from "@/collections/intelligence/MissionAttendances";
|
|
import { MissionRoleSignups } from "@/collections/intelligence/MissionRoleSignups";
|
|
import { Maps } from "@/collections/world/Maps";
|
|
import { MapRoads } from "@/collections/world/MapRoads";
|
|
import { MapZones } from "@/collections/world/MapZones";
|
|
import { ResourceNodes } from "@/collections/world/ResourceNodes";
|
|
import { Campaigns } from "@/collections/intelligence/Campaigns";
|
|
import { Qualifications } from "@/collections/users/Qualifications";
|
|
import { MissionFiles } from "@/collections/server/MissionFiles";
|
|
import { ModLists } from "@/collections/server/ModLists";
|
|
import { GameServers } from "@/collections/server/GameServers";
|
|
import { ArmaSyncEvents } from "@/collections/server/ArmaSyncEvents";
|
|
import { ArmaCommands } from "@/collections/server/ArmaCommands";
|
|
import { RespawnTickets } from "@/collections/server/RespawnTickets";
|
|
import { SupplyBoxes } from "@/collections/server/SupplyBoxes";
|
|
import { OperationEvents } from "@/collections/operations/OperationEvents";
|
|
import { OperationEffects } from "@/collections/operations/OperationEffects";
|
|
import { OperationReservations } from "@/collections/operations/OperationReservations";
|
|
import { OperationReadiness } from "@/collections/operations/OperationReadiness";
|
|
import { ZonePressure } from "@/collections/operations/ZonePressure";
|
|
import { Factions } from "@/collections/intelligence/Factions";
|
|
import { Assets } from "@/collections/logistics/Assets";
|
|
import { NarrativeEvents } from "@/collections/world/NarrativeEvents";
|
|
import { Resources } from "@/collections/logistics/Resources";
|
|
import { Vehicles } from "@/collections/logistics/Vehicles";
|
|
import { Structures } from "@/collections/logistics/Structures";
|
|
import { Shipments } from "@/collections/logistics/Shipments";
|
|
import { Assignments } from "@/collections/users/Assignments";
|
|
import { AssignmentTransfers } from "@/collections/users/AssignmentTransfers";
|
|
import { Technologies } from "@/collections/intelligence/Technologies";
|
|
import { TechCategories } from "@/collections/intelligence/TechCategories";
|
|
import { GameRules } from "@/collections/game/GameRules";
|
|
import { GameStructures } from "@/collections/game/GameStructures";
|
|
import { GameHardResources } from "@/collections/game/GameHardResources";
|
|
import { GameEventLogs } from "@/collections/game/GameEventLogs";
|
|
import { GameVehicles } from "@/collections/game/GameVehicles";
|
|
import { GameNpcs } from "@/collections/game/GameNpcs";
|
|
import { NpcStaffing } from "@/collections/game/NpcStaffing";
|
|
import { LaborClassifications } from "@/collections/game/LaborClassifications";
|
|
import { Experience } from "@/collections/users/Experience";
|
|
import { BankAccounts } from "@/collections/banking/BankAccounts";
|
|
import { BankTransactions } from "@/collections/banking/BankTransactions";
|
|
import { LedgerEntries } from "@/collections/banking/LedgerEntries";
|
|
import { LockerStorages } from "@/collections/locker/LockerStorages";
|
|
import { Loadouts } from "@/collections/locker/Loadouts";
|
|
import { MarketListings } from "@/collections/market/MarketListings";
|
|
import { MarketNegotiations } from "@/collections/market/MarketNegotiations";
|
|
import { MarketState } from "@/collections/market/MarketState";
|
|
import { VoiceScripts } from "@/collections/voiceover/VoiceScripts";
|
|
import { VoiceSubmissions } from "@/collections/voiceover/VoiceSubmissions";
|
|
import { UserNotifications } from "@/collections/users/UserNotifications";
|
|
import { Evaluations } from "@/collections/users/Evaluations";
|
|
import { Tickets } from "@/collections/tickets/Tickets";
|
|
import { TicketVotes } from "@/collections/tickets/TicketVotes";
|
|
import { WikiPages } from "@/collections/wiki/WikiPages";
|
|
import { WikiRevisions } from "@/collections/wiki/WikiRevisions";
|
|
import { WikiTemplates } from "@/collections/wiki/WikiTemplates";
|
|
import { Projects } from "@/collections/projects/Projects";
|
|
import { Sprints } from "@/collections/projects/Sprints";
|
|
import { Releases } from "@/collections/projects/Releases";
|
|
import { Labels } from "@/collections/projects/Labels";
|
|
import { StoryBeats } from "@/collections/story/StoryBeats";
|
|
import { StoryBeatStates } from "@/collections/story/StoryBeatStates";
|
|
import { Voiceovers } from "@/collections/story/Voiceovers";
|
|
import { Shims } from "@/collections/Shims";
|
|
import { Announcements } from "@/collections/Announcements";
|
|
import { CustomMinefields } from "@/collections/minigames/CustomMinefields";
|
|
import { CustomMinefieldScores } from "@/collections/minigames/CustomMinefieldScores";
|
|
import { CustomRadioTests } from "@/collections/minigames/CustomRadioTests";
|
|
import { CustomRadioTestScores } from "@/collections/minigames/CustomRadioTestScores";
|
|
import { RibbonSubmissions } from "@/collections/users/RibbonSubmissions";
|
|
import hasRoles from "@/utils/access-control/hasRoles";
|
|
import { scopedAdminPageAccess } from "@/utils/access-control/divisionAccess";
|
|
import { payloadAiPlugin, PayloadAiPluginLexicalEditorFeature } from "@ai-stack/payloadcms";
|
|
import type { SeedPromptFunction } from "@ai-stack/payloadcms/types";
|
|
import { mcpPlugin } from "@payloadcms/plugin-mcp";
|
|
import { formBuilderPlugin } from "@payloadcms/plugin-form-builder";
|
|
import { surveyFormBuilderConfig } from "@/lib/surveys/formBuilderConfig";
|
|
import { createOpenAI } from "@ai-sdk/openai";
|
|
import { createTextStreamResponse, jsonSchema, streamObject } from "ai";
|
|
import type { User } from "@/payload-types";
|
|
|
|
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-26B-A4B-it-qat-GGUF";
|
|
|
|
// AI Compose allowlist: the plugin's default init makes an LLM call per field
|
|
// to seed prompts. Returning false here skips a field (no instruction row, no
|
|
// AI UI), and returning { data: { prompt } } (without a top-level `prompt` key)
|
|
// seeds the row statically, so no LLM call ever happens at boot and a wiped
|
|
// instructions table (e.g. after scripts/sync-prod.sh) rebuilds in
|
|
// milliseconds on the next startup.
|
|
const AI_TEXT_FIELD_ALLOWLIST = new Set([
|
|
"description",
|
|
"gamedescription",
|
|
"summary",
|
|
"bio",
|
|
"occupation",
|
|
"name",
|
|
"title",
|
|
]);
|
|
|
|
// Textarea/richText fields that are system-managed or technical, never AI-able.
|
|
const AI_FIELD_DENYLIST = new Set(["contentsnapshot", "html", "script"]);
|
|
|
|
const staticAiPrompt = (fieldName: string, fieldLabel: string, fieldType: string): string => {
|
|
switch (fieldName) {
|
|
case "description":
|
|
case "gamedescription":
|
|
return `Write an immersive, in-universe description for the "${fieldLabel}" of {{ title }}. 2-4 sentences of plain prose. No markdown, no lists, no headings.`;
|
|
case "summary":
|
|
return `Write a concise summary for the "${fieldLabel}" of {{ title }}. 1-3 sentences of plain prose.`;
|
|
case "bio":
|
|
return `Write a short in-universe biography for {{ title }}. A few sentences of plain prose covering background and personality.`;
|
|
case "occupation":
|
|
return `Suggest a short occupation label for {{ title }}. Reply with 1-3 words only, no explanation.`;
|
|
case "name":
|
|
return `Suggest a single fitting name for {{ title }}. Reply with the name only, no explanation.`;
|
|
case "title":
|
|
return `Suggest a single fitting title for {{ title }}. Reply with the title only, no explanation.`;
|
|
default:
|
|
if (fieldType === "richText") {
|
|
return `Write long-form content for the "${fieldLabel}" section of {{ title }}. Structured prose with an introduction, clear sections, and a conclusion.`;
|
|
}
|
|
return `Write suitable content for the "${fieldLabel}" of {{ title }}. Plain prose, concise and relevant.`;
|
|
}
|
|
};
|
|
|
|
const aiFieldSeedPrompts: SeedPromptFunction = ({ path, fieldType, fieldLabel }) => {
|
|
const fieldName = (path.split(".").pop() ?? "").toLowerCase();
|
|
|
|
if (AI_FIELD_DENYLIST.has(fieldName)) return false;
|
|
|
|
if (fieldType === "textarea" || fieldType === "richText") {
|
|
return { data: { prompt: staticAiPrompt(fieldName, fieldLabel, fieldType) } };
|
|
}
|
|
|
|
if (fieldType === "text" && AI_TEXT_FIELD_ALLOWLIST.has(fieldName)) {
|
|
return { data: { prompt: staticAiPrompt(fieldName, fieldLabel, fieldType) } };
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
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,
|
|
},
|
|
};
|
|
}
|
|
|
|
// Gemma tends to answer schema-y prompts with fenced JSON blobs. The plain-text
|
|
// fallback is meant to produce prose that plainTextToLexicalJson wraps into
|
|
// paragraphs, so strip any markdown code fences before using the output.
|
|
const stripCodeFences = (text: string): string =>
|
|
text
|
|
.replace(/^```[a-zA-Z]*\s*\n?/, "")
|
|
.replace(/\n?```\s*$/, "")
|
|
.trim();
|
|
|
|
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 stripCodeFences(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.";
|
|
|
|
const collections = [
|
|
Media,
|
|
|
|
// Announcements
|
|
Announcements,
|
|
|
|
// Game
|
|
GameHardResources,
|
|
GameStructures,
|
|
GameVehicles,
|
|
GameNpcs,
|
|
NpcStaffing,
|
|
LaborClassifications,
|
|
GameEventLogs,
|
|
|
|
// Users
|
|
Users,
|
|
Roles,
|
|
Ranks,
|
|
Profiles,
|
|
Awards,
|
|
Qualifications,
|
|
Assignments,
|
|
AssignmentTransfers,
|
|
Experience,
|
|
UserNotifications,
|
|
Evaluations,
|
|
RibbonSubmissions,
|
|
|
|
// Intel
|
|
Missions,
|
|
MissionAttendances,
|
|
MissionRoleSignups,
|
|
Campaigns,
|
|
Factions,
|
|
Technologies,
|
|
TechCategories,
|
|
|
|
// Logistics
|
|
Assets,
|
|
Resources,
|
|
Vehicles,
|
|
Structures,
|
|
Shipments,
|
|
|
|
// Banking
|
|
BankAccounts,
|
|
BankTransactions,
|
|
LedgerEntries,
|
|
|
|
// Locker
|
|
LockerStorages,
|
|
Loadouts,
|
|
|
|
// Market
|
|
MarketListings,
|
|
MarketNegotiations,
|
|
MarketState,
|
|
|
|
// Voiceover
|
|
VoiceScripts,
|
|
VoiceSubmissions,
|
|
|
|
// Helpdesk
|
|
Tickets,
|
|
TicketVotes,
|
|
|
|
// Wiki
|
|
WikiPages,
|
|
WikiRevisions,
|
|
WikiTemplates,
|
|
|
|
// Projects
|
|
Projects,
|
|
Sprints,
|
|
Releases,
|
|
Labels,
|
|
|
|
// World
|
|
Maps,
|
|
MapRoads,
|
|
MapZones,
|
|
ResourceNodes,
|
|
NarrativeEvents,
|
|
|
|
// Story
|
|
StoryBeats,
|
|
StoryBeatStates,
|
|
Voiceovers,
|
|
|
|
// Server
|
|
MissionFiles,
|
|
ModLists,
|
|
GameServers,
|
|
ArmaSyncEvents,
|
|
ArmaCommands,
|
|
RespawnTickets,
|
|
SupplyBoxes,
|
|
|
|
// Operations
|
|
OperationEvents,
|
|
OperationEffects,
|
|
OperationReservations,
|
|
OperationReadiness,
|
|
ZonePressure,
|
|
|
|
// Minigames
|
|
CustomMinefields,
|
|
CustomMinefieldScores,
|
|
CustomRadioTests,
|
|
CustomRadioTestScores,
|
|
];
|
|
|
|
// Scoped admin pages: a user needs BOTH `<slug>:read` AND `admin:<slug>:manage`
|
|
// to see/interact with a collection in the Payload admin panel, except the four
|
|
// division-scoped collections (technologies → intelligence; assets/resources/
|
|
// vehicles → logistics), where a division member passes instead. Non-admin
|
|
// (REST/API) reads keep the collection's original access behavior.
|
|
const scopedCollections = collections.map((collection) => ({
|
|
...collection,
|
|
access: {
|
|
...collection.access,
|
|
read: scopedAdminPageAccess(collection.slug, collection.access?.read),
|
|
},
|
|
}));
|
|
|
|
// MCP: eligible per collection, not granted: `enabled: true` makes all CRUD
|
|
// capabilities available, but each MCP API key still picks its own capability
|
|
// checkboxes and each collection's access control still applies.
|
|
const mcpCollections = Object.fromEntries(
|
|
scopedCollections.map((collection) => [collection.slug, { enabled: true } as const]),
|
|
);
|
|
|
|
export default buildConfig({
|
|
// serverURL: "http://localhost:3000/",
|
|
admin: {
|
|
user: Users.slug,
|
|
importMap: {
|
|
baseDir: path.resolve(dirname),
|
|
},
|
|
meta: {
|
|
titleSuffix: "- Polaris Task Force",
|
|
applicationName: META_TITLE,
|
|
abstract: META_DESCRIPTION,
|
|
keywords: "Polaris Task Force, Polaris, Task Force, Polaris Task Force official website",
|
|
description: META_DESCRIPTION,
|
|
openGraph: {
|
|
title: META_TITLE,
|
|
description: META_DESCRIPTION,
|
|
url: "https://polaris-task-force.com",
|
|
siteName: META_TITLE,
|
|
images: [
|
|
{
|
|
url: "/images/icon-favicon.png",
|
|
width: 512,
|
|
height: 512,
|
|
alt: "Polaris Task Force Logo",
|
|
},
|
|
],
|
|
},
|
|
icons: [
|
|
{
|
|
rel: "icon",
|
|
type: "image/png",
|
|
url: "/images/icon-favicon.png",
|
|
},
|
|
],
|
|
},
|
|
|
|
components: {
|
|
beforeDashboard: ["@/components/static/BeforeDashboardText"],
|
|
header: ["@/components/static/VersionOverlay"],
|
|
beforeNav: ["@/components/static/GoToFrontendLink"],
|
|
graphics: {
|
|
Logo: "@/components/graphics/AppLogo",
|
|
Icon: "@/components/graphics/AppIcon",
|
|
},
|
|
Nav: "@/components/admin/tech-tree/TechTreeNav",
|
|
views: {
|
|
techTree: {
|
|
path: "/tech-tree",
|
|
Component: "@/components/admin/tech-tree/TechTreeView",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
globals: [GameRules, Shims],
|
|
collections: scopedCollections,
|
|
editor: lexicalEditor({
|
|
features: ({ defaultFeatures }) => {
|
|
return [...defaultFeatures, FixedToolbarFeature(), PayloadAiPluginLexicalEditorFeature()];
|
|
},
|
|
}),
|
|
secret: process.env.PAYLOAD_SECRET || "",
|
|
typescript: {
|
|
outputFile: path.resolve(dirname, "payload-types.ts"),
|
|
},
|
|
db: postgresAdapter({
|
|
pool: {
|
|
connectionString: process.env.DATABASE_URI || "",
|
|
},
|
|
// push: process.env.NODE_ENV === "development",
|
|
// Env-gated drizzle push: only the test-database bootstrap (DB_PUSH=1) uses
|
|
// it, because the migration chain assumes a schema that push created.
|
|
push: process.env.DB_PUSH === "1",
|
|
prodMigrations: migrations,
|
|
}),
|
|
sharp,
|
|
bin: [
|
|
{
|
|
scriptPath: path.resolve(dirname, "scripts/gameTick.ts"),
|
|
key: "game-tick",
|
|
},
|
|
{
|
|
scriptPath: path.resolve(dirname, "scripts/marketTick.ts"),
|
|
key: "market-tick",
|
|
},
|
|
{
|
|
scriptPath: path.resolve(dirname, "scripts/missionTick.ts"),
|
|
key: "mission-tick",
|
|
},
|
|
{
|
|
scriptPath: path.resolve(dirname, "scripts/serverTick.ts"),
|
|
key: "server-tick",
|
|
},
|
|
{
|
|
scriptPath: path.resolve(dirname, "scripts/baseTick.ts"),
|
|
key: "base-tick",
|
|
},
|
|
{
|
|
scriptPath: path.resolve(dirname, "scripts/economyTick.ts"),
|
|
key: "economy-tick",
|
|
},
|
|
{
|
|
scriptPath: path.resolve(dirname, "scripts/generateNextMission.ts"),
|
|
key: "generate-mission",
|
|
},
|
|
],
|
|
plugins: [
|
|
formBuilderPlugin(surveyFormBuilderConfig()),
|
|
// payloadCloudPlugin(),
|
|
// storage-adapter-placeholder
|
|
mcpPlugin({
|
|
collections: mcpCollections,
|
|
overrideApiKeyCollection: (collection) => ({
|
|
...collection,
|
|
admin: {
|
|
...collection.admin,
|
|
hidden: ({ user }) => !hasRoles(["admin", "developer"], user as User),
|
|
},
|
|
access: {
|
|
...collection.access,
|
|
read: ({ req }) => hasRoles(["admin", "developer"], req.user as User),
|
|
update: ({ req }) => hasRoles(["admin", "developer"], req.user as User),
|
|
create: ({ req }) => hasRoles(["admin", "developer"], req.user as User),
|
|
delete: ({ req }) => hasRoles(["admin", "developer"], req.user as User),
|
|
},
|
|
}),
|
|
}),
|
|
payloadAiPlugin({
|
|
seedPrompts: aiFieldSeedPrompts,
|
|
collections: {
|
|
[GameHardResources.slug]: true,
|
|
[GameStructures.slug]: true,
|
|
[GameVehicles.slug]: true,
|
|
[GameNpcs.slug]: true,
|
|
[NpcStaffing.slug]: true,
|
|
[LaborClassifications.slug]: true,
|
|
[GameEventLogs.slug]: true,
|
|
|
|
// Users
|
|
[Users.slug]: true,
|
|
[Roles.slug]: true,
|
|
[Ranks.slug]: true,
|
|
[Profiles.slug]: true,
|
|
[Awards.slug]: true,
|
|
[Qualifications.slug]: true,
|
|
[Assignments.slug]: true,
|
|
[Experience.slug]: true,
|
|
[UserNotifications.slug]: true,
|
|
[Evaluations.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,
|
|
|
|
// Wiki
|
|
[WikiPages.slug]: true,
|
|
[WikiRevisions.slug]: true,
|
|
[WikiTemplates.slug]: true,
|
|
|
|
// Projects
|
|
[Projects.slug]: true,
|
|
[Sprints.slug]: true,
|
|
[Releases.slug]: true,
|
|
[Labels.slug]: true,
|
|
},
|
|
debugging: true,
|
|
overrideInstructions: {
|
|
admin: {
|
|
hidden: ({ user }) => !hasRoles(["admin", "developer"], user as User),
|
|
},
|
|
access: {
|
|
read: ({ req }) => hasRoles(["admin", "developer"], req.user as User),
|
|
update: ({ req }) => hasRoles(["admin", "developer"], req.user as User),
|
|
create: ({ req }) => hasRoles(["admin", "developer"], req.user as User),
|
|
delete: ({ req }) => hasRoles(["admin", "developer"], req.user as User),
|
|
},
|
|
},
|
|
generationModels: [
|
|
{
|
|
id: "unsloth/gemma-4-26B-A4B-it-qat-GGUF",
|
|
name: "Gemma 4 26B A4B",
|
|
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}\n\nOUTPUT RULES:\nRespond with plain prose paragraphs only. Do not output JSON, code fences, or schema definitions. Plain sentences and paragraphs are the entire expected output.`,
|
|
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: "gemma-4-settings",
|
|
type: "group",
|
|
admin: {
|
|
condition(data) {
|
|
return data["model-id"] === "unsloth/gemma-4-26B-A4B-it-qat-GGUF";
|
|
},
|
|
},
|
|
fields: [
|
|
{
|
|
name: "model",
|
|
type: "text",
|
|
defaultValue: DEFAULT_MODEL,
|
|
label: "Model",
|
|
},
|
|
],
|
|
label: "Gemma 4 Settings",
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
],
|
|
email: nodemailerAdapter({
|
|
defaultFromAddress: process.env.EMAIL_FROM_ADDRESS || "",
|
|
defaultFromName: process.env.EMAIL_FROM_NAME || "",
|
|
transport: nodemailer.createTransport({
|
|
host: process.env.EMAIL_HOST || "",
|
|
port: Number(process.env.EMAIL_PORT) || 587,
|
|
auth: {
|
|
user: process.env.EMAIL_USERNAME || "",
|
|
pass: process.env.EMAIL_PASSWORD || "",
|
|
},
|
|
}),
|
|
}),
|
|
});
|