From 42e71fb502ddf68a0a1c381a9f27c66b3ce5e09b Mon Sep 17 00:00:00 2001 From: fretuks Date: Tue, 13 Oct 2026 22:14:51 +0200 Subject: [PATCH] "Improve personality registry validation and API --- src/routes/chats.js | 32 +++++++---- src/services/personalityRegistry.js | 75 ++++++++++++++++++++++---- src/services/workspaceImportService.js | 4 +- test/app.test.js | 2 + 4 files changed, 91 insertions(+), 22 deletions(-) diff --git a/src/routes/chats.js b/src/routes/chats.js index 1defb67..5f15eb6 100644 --- a/src/routes/chats.js +++ b/src/routes/chats.js @@ -7,14 +7,20 @@ import {asyncHandler} from "../middleware/asyncHandler.js"; import {generationCancellation} from "../middleware/generationCancellation.js"; import {requireLogin} from "../middleware/auth.js"; import {validateBody} from "../middleware/validate.js"; -import {getPersonality, listPersonalities, DEFAULT_PERSONALITY_ID} from "../services/personalityRegistry.js"; +import {getPersonality, getPersonalityMetadata, listPersonalities, DEFAULT_PERSONALITY_ID} from "../services/personalityRegistry.js"; -const createChatValidator = (body) => ({ - value: { - title: String(body?.title || "New chat").trim() || "New chat", - personalityId: body?.personalityId == null ? DEFAULT_PERSONALITY_ID : String(body.personalityId) +const createChatValidator = (body) => { + const rawPersonalityId = body?.personalityId; + if (rawPersonalityId != null && (typeof rawPersonalityId !== "string" || !rawPersonalityId.trim())) { + return {error: "personalityId must be a non-empty string"}; } -}); + return { + value: { + title: String(body?.title || "New chat").trim() || "New chat", + personalityId: rawPersonalityId == null ? DEFAULT_PERSONALITY_ID : rawPersonalityId.trim() + } + }; +}; const messageValidator = (body) => { const content = String(body?.content || "").trim(); @@ -147,7 +153,7 @@ export function createChatsRouter({repositories, chatService, modelService, conf router.get("/chats", (req, res) => res.json({chats: repositories.listChats(req.session.user)})); router.get("/personalities", (_req, res) => res.json({personalities: listPersonalities(), defaultPersonalityId: DEFAULT_PERSONALITY_ID})); router.get("/personalities/:id", (req, res) => { - const personality = listPersonalities().find(item => item.id === req.params.id); + const personality = getPersonalityMetadata(req.params.id); return personality ? res.json({personality}) : res.status(404).json({error: "Personality not found"}); }); router.get("/chats/search", (req, res) => res.json({chats: repositories.searchChats(req.session.user, req.query.q || "")})); @@ -170,10 +176,14 @@ export function createChatsRouter({repositories, chatService, modelService, conf const chatId = Number(req.params.id); const chat = repositories.getChat(chatId, req.session.user); if (!chat) return res.status(404).json({error: "Chat not found"}); - const personalityId = String(req.body?.personalityId || ""); - if (!getPersonality(personalityId)) return res.status(400).json({error: "Unknown personality"}); - repositories.updateChatPersonality(chatId, req.session.user, personalityId); - return res.json({chat: repositories.getChat(chatId, req.session.user), personality: listPersonalities().find(item => item.id === personalityId)}); + const personalityId = req.body?.personalityId; + if (typeof personalityId !== "string" || !personalityId.trim()) { + return res.status(400).json({error: "personalityId must be a non-empty string"}); + } + const normalizedPersonalityId = personalityId.trim(); + if (!getPersonality(normalizedPersonalityId)) return res.status(400).json({error: "Unknown personality"}); + repositories.updateChatPersonality(chatId, req.session.user, normalizedPersonalityId); + return res.json({chat: repositories.getChat(chatId, req.session.user), personality: getPersonalityMetadata(normalizedPersonalityId)}); }); router.put("/chats/:id", validateBody((body) => { diff --git a/src/services/personalityRegistry.js b/src/services/personalityRegistry.js index 6be5327..df0f37e 100644 --- a/src/services/personalityRegistry.js +++ b/src/services/personalityRegistry.js @@ -1,3 +1,5 @@ +export const DEFAULT_PERSONALITY_ID = "general-assistant"; + const entries = [ { id: "general-assistant", name: "Leonardo", specialization: "General Assistance", @@ -79,15 +81,54 @@ Present the reasoning in a readable order: frame the problem, identify decisive } ]; -const registry = new Map(entries.map(entry => [entry.id, Object.freeze(entry)])); -export const DEFAULT_PERSONALITY_ID = "general-assistant"; +const requiredMetadataFields = [ + "id", + "name", + "specialization", + "description", + "longDescription", + "historicalInspiration", + "icon", + "accentColor", + "systemPrompt" +]; -export function getPersonality(id) { - return registry.get(id) || null; +function createRegistry(definitions) { + const ids = new Set(); + const defaultEntries = definitions.filter(entry => entry.id === DEFAULT_PERSONALITY_ID); + + if (defaultEntries.length !== 1) { + throw new Error(`Personality registry must contain exactly one default personality: ${DEFAULT_PERSONALITY_ID}`); + } + + for (const entry of definitions) { + if (!entry || typeof entry !== "object") throw new Error("Personality registry entries must be objects"); + for (const field of requiredMetadataFields) { + if (typeof entry[field] !== "string" || !entry[field].trim()) { + throw new Error(`Personality ${entry.id || ""} is missing ${field}`); + } + } + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(entry.id)) { + throw new Error(`Personality ID is not kebab-case: ${entry.id}`); + } + if (ids.has(entry.id)) throw new Error(`Duplicate personality ID: ${entry.id}`); + if (!/^#[0-9a-f]{6}$/i.test(entry.accentColor)) { + throw new Error(`Invalid accent color for personality: ${entry.id}`); + } + if (entry.systemPrompt.length < 1200) { + throw new Error(`Personality ${entry.id} needs a substantial system prompt`); + } + ids.add(entry.id); + } + + return new Map(definitions.map(entry => [entry.id, Object.freeze({...entry})])); } -export function listPersonalities() { - return entries.map(entry => ({ +const registry = createRegistry(entries); + +function toPublicMetadata(entry) { + if (!entry) return null; + return { id: entry.id, name: entry.name, specialization: entry.specialization, @@ -96,10 +137,26 @@ export function listPersonalities() { historicalInspiration: entry.historicalInspiration, icon: entry.icon, accentColor: entry.accentColor, - ...(entry.isDefault ? {isDefault: true} : {}) - })); + isDefault: entry.id === DEFAULT_PERSONALITY_ID + }; +} + +export function getPersonality(id) { + return registry.get(id) || null; +} + +export function resolvePersonalityId(id) { + return registry.has(id) ? id : DEFAULT_PERSONALITY_ID; +} + +export function listPersonalities() { + return entries.map(toPublicMetadata); +} + +export function getPersonalityMetadata(id) { + return toPublicMetadata(getPersonality(id)); } export function getPersonalitySystemPrompt(id) { - return getPersonality(id)?.systemPrompt || registry.get(DEFAULT_PERSONALITY_ID).systemPrompt; + return registry.get(resolvePersonalityId(id)).systemPrompt; } diff --git a/src/services/workspaceImportService.js b/src/services/workspaceImportService.js index b41360a..8763047 100644 --- a/src/services/workspaceImportService.js +++ b/src/services/workspaceImportService.js @@ -2,7 +2,7 @@ * @file Prüft und normalisiert importierte Arbeitsbereiche mit Personas, Chats und Nachrichtenbeziehungen. */ import {parseSources} from "./chatMessageHelpers.js"; -import {getPersonality, DEFAULT_PERSONALITY_ID} from "./personalityRegistry.js"; +import {resolvePersonalityId} from "./personalityRegistry.js"; const VALID_PERSONA_TYPES = new Set(["assistant", "user"]); const VALID_MESSAGE_ROLES = new Set(["user", "bot", "assistant", "system"]); @@ -197,7 +197,7 @@ function validateChat(chat, index, errors) { branched_from_message_id: sourceId(chat.branched_from_message_id ?? chat.branchedFromMessageId), title, stored_title: title, - personality_id: getPersonality(chat.personality_id ?? chat.personalityId)?.id || DEFAULT_PERSONALITY_ID, + personality_id: resolvePersonalityId(chat.personality_id ?? chat.personalityId), folder_name: optionalText(chat.folder_name || chat.folderName), is_pinned: Boolean(chat.is_pinned || chat.isPinned), archived_at: optionalText(chat.archived_at || chat.archivedAt), diff --git a/test/app.test.js b/test/app.test.js index d8594ab..e95c38d 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -158,7 +158,9 @@ test("personality API keeps prompts private, validates IDs, and enforces chat ow const defaultChat = await request(server.baseUrl, "/chats", {method:"POST", jar:owner, body:{systemPrompt:"Injected"}}); assert.equal(defaultChat.json.chat.personality_id, "general-assistant"); assert.equal((await request(server.baseUrl, "/chats", {method:"POST", jar:owner, body:{personalityId:"invented"}})).status, 400); + assert.equal((await request(server.baseUrl, "/chats", {method:"POST", jar:owner, body:{personalityId:["programmer"]}})).status, 400); assert.equal((await request(server.baseUrl, `/chats/${created.json.chat.id}/personality`, {method:"PUT", jar:other, body:{personalityId:"tutor"}})).status, 404); + assert.equal((await request(server.baseUrl, `/chats/${created.json.chat.id}/personality`, {method:"PUT", jar:owner, body:{personalityId:["tutor"]}})).status, 400); assert.equal((await request(server.baseUrl, `/chats/${created.json.chat.id}/personality`, {method:"PUT", jar:owner, body:{personalityId:"tutor"}})).json.chat.personality_id, "tutor"); assert.equal((await request(server.baseUrl, "/personas", {method:"POST", jar:owner, body:{name:"Custom"}})).status, 404); });