Services
The service layer holds Marginalia's logic: everything the UI does to data goes through a service, and services are the main API extensions use. This page describes how services and repositories are built, how transactions and the current user work, and what each service is for. The generation engine has its own page, Generation pipeline.
Layers
flowchart LR
UI["UI / extensions<br/>(@Configurable, @Autowired)"] --> S["Service interface<br/>domain/service"]
S -.-> P["Spring CGLIB proxy<br/>transactions"]
P --> I["Service implementation<br/>domain/service/impl"]
I --> R["Repository<br/>domain/repository/impl/Jpa*"]
R --> EM["EntityManager<br/>(shared, transaction-bound)"]
I --> O["other services"]-
Interfaces live in
domain/service/(anddomain/security/service/), implementations in.../impl/. Code always depends on the interface. -
Beans are declared in
META-INF/spring/services-config.xml. Each entity service gets its repository injected through therepositoryproperty; everything else is@Autowiredinside the implementation. -
Repositories (
domain/repository/impl/Jpa*Repository) contain the JPQL and native SQL. They are called only by their service.
The generic service hierarchy
Entity services extend a generic base that matches the entity's base class:
The same three levels exist for repositories: JpaBaseRepository → JpaOwnedRepository →
JpaExtendableRepository. A repository only has to name its entity class (getEntityClass()) to get the generic
queries; it can override hydrate(entity) to post-process loaded entities.
Things to know about the generic methods:
-
find(id)doesn't check owner ordeleted. Use it for ids that come from the user's own data (a book'sai, a part'sparent). For anything that comes from outside - a URL, an uploaded file, a backup - usefindForUser(uuid), which returnsnullfor other users' and deleted entities. -
find(entity)reloads. Entities held by the UI are detached and may be stale; services usually start withManuscript m = find(manuscript)before changing it. -
findAll*skip deleted rows,find(id)doesn't. -
savereturns the managed copy (merge). Keep using the returned object. -
savevs.saveWithoutEvent-saveserializes the extended attributes intoextendedContent;saveWithoutEventstoresextendedContentas it is (used when it was copied from a backup). Both keep the_fulltextcolumn in step with the@Fulltextablefields, see Full-text column. -
delete(entity, false)soft deletes (and, in the services of entities that own other rows - manuscript, message, lorebook, entry, AI, tag - the owned rows with it),delete(entity, true)removes the row. See Soft delete.
Transactions
Transactions are declared with three meta-annotations from domain/traits/ and applied by Spring's
tx:annotation-driven with class-based (CGLIB) proxies around each service bean (proxy-target-class="true"):
The rules that follow from proxies:
-
Only calls through a bean reference are transactional. A service calling its own method (
this.save(...)) bypasses the proxy, so that method's annotation has no effect. When a service needs its own transactional method from a callback, it injects itself -SummaryServiceImplhas@Autowired private SummaryService selfand callsself.save(summary)from the streaming callback. -
Transactions are short. SQLite allows one writer at a time (see SQLite limitations); a long write transaction blocks every other user's writes. Never call a model, wait for the UI or sleep inside a transaction. Streaming code saves through the service for each change, so each save is its own transaction.
-
A transaction ends with the outermost annotated call. Entities returned to the UI are detached. Lazy associations (
ChatMessage.parent,ChatMessage.parentScript,LorebookEntry.lorebook) can't be navigated after that - reload them through the service (chatMessageService.getParent(node),find(...)). -
Methods without an annotation run in the caller's transaction, or without one.
The current user
Services are singletons, but the user differs per browser session. The session-scoped bean user
(domain.security.model.User behind a scoped proxy, defined in container-config.xml) is injected into services as
@Autowired
protected User currentUser;
Each call on currentUser is resolved against the HTTP session of the current thread. It holds a copy of the
logged-in user's id, login and fullName - load the entity (userService.find(currentUser.getId())) for
anything else, such as isAdmin().
Services that depend on the current user:
-
OwnedServiceImpl-findAllForUser,findForUser, and the owner of new entities, -
SettingService.getOrCreate(UserSetting.class)- the user's settings, -
ManuscriptService- searches,findViewable,markOpened, and the prompt defaults (getMasterTemplate... fall back to the current user'sUserSetting), -
administrator checks:
AdminGuard.requireAdmin()(seeAdministration ).
On background threads there is no request, so resolving currentUser fails with No thread-bound request
found. Capture the request attributes on the UI thread and enter them on the worker:
ThreadCopyRequestAttributes attributes = ThreadCopyRequestAttributes.create(); // on the UI thread
executor.submit(() -> {
try (InRequestScope _ = new InRequestScope(attributes)) { // on the worker
lorebookService.findAllForUser(); // current user resolves
}
});
create() answers with the copy of a browser session, with the attributes of an agent run as they are (see
Agents), and with null on a thread that has no context. The generation engine, SummaryServiceImpl and ThreadAccessDialog do this. Scheduled jobs (database backups) run
without any user and only use installation-wide data (getOrCreateApp).
Services by area
Books and the story
Images in backups. A stored backup is JSON and never contains image files, only the imageAttachments of the
parts (resource uuids) in their extendedContent. ManuscriptBackup.imageCount is a header field (read by
parseMetadataOnly without loading the backup) so the UI knows whether to ask. BackupImages reads and rewrites the
attachments inside the backup JSON (collect, remap where returning null drops the image).
exportBackup(backup, withImages, onImage) writes to a temp file and returns a BackupExport; with images it is a ZIP:
backup.json (the stored file, streamed), resources.json ({"version":1,"resources":[{uuid, entry, name, mimeType, hash, size}]}) and resources/<n>.<ext>, one image at a time with Files.copy, so nothing but the JSON tree is in memory.
Import, restore and analyzeLorebooks detect a ZIP by its first bytes (openSource) and read entries only by the names
the manifest lists under resources/ (no extraction to disk). Each image is read bounded by MAX_IMAGE_BYTES and goes
through ResourceService.uploadImage, so it is validated again and the hash dedupe reuses an existing file.
Resources are not shared by books, so: importBackup keeps a uuid the user already has with the same hash and otherwise
adds the image (adopting), restoreAsNewManuscript and cloneBackup(..., withImages) make a new Resource for every
use (from the archive, or ResourceService.copy of the user's own resource, which points to the same file); images that
can't be found are dropped from the parts. After every restore linkImages notes the new parts in the resources whose
link is empty or stale. The ZIP is only a transfer format, a stored backup stays a JSON file.
The automatic book backups (BackupStrategy.AFTER_N_MESSAGES / AFTER_N_MINUTES) are triggered by the story editor
after a part is generated (ManuscriptStoryPart), not by a service - code that generates parts without the editor
doesn't take them.
Story export
ExportDialog (opened from the settings menu of ManuscriptStoryPart) collects the active branch in one
ProgressBarDialog (indeterminate), cuts the chosen range and exports it in a second one whose total is the number of
messages, then shows the download link. The exporter gets ExportOptions (title, author, title page, language, and the PartStyle chosen in the dialog, null for the look of the format), the
messages and the progress dialog, on which it calls updateProgress() for every message.
ExportOptions.templates holds the templates the user changed in the settings (by ExportTemplate.key(); templateText(template) gives the changed text or the default), which the dialog fills from ExportSettingsService.getTemplates on the UI thread, since the exporters run on a worker thread without the user's session.
An exporter lists the templates it renders in Exporter.getTemplates() (ExportTemplate: key, label, default text, the TemplateData class that lists the variables); ExporterBase has the title page (TITLE_PAGE_TEMPLATE, ExportHeaderTemplateData: title, author), so every built-in format has it. Exporter.getId() (the file extension unless overridden) is the key of the user's settings for the exporter and must stay stable.
The exporters for other tools (registered after the first six, all with an own getId() because their file extension is taken or too general): Ao3HtmlExporter (ao3-html: the text of every chapter between BEGIN / END comments for the HTML editor of AO3, a renderer with soft breaks as spaces and no line breaks between tags, no templates), FrontMatterMarkdownExporter (markdown-front-matter: extends MarkdownExporter, writes ExporterBase.FRONT_MATTER_TEMPLATE (ExportFrontMatterTemplateData: title and author escaped for double quotes, language, exportDate) instead of the title page), SillyTavernExporter (sillytavern: implements Exporter directly, JSON Lines of ChatMessage.instructions as the user's and response as the character's messages) and ObsidianExporter (obsidian: a ZIP of notes, usesLore(); the first mention of an entry name in a note becomes [[path|mention]]). Exporter.usesLore() (default false) makes the dialog fill ExportOptions.lore through ExporterService.collectLore on the UI thread.
ExporterBase does the common work: it renders the title page from Defaults.DEFAULT_EXPORT_HEADER_TEMPLATE
(Handlebars, data ExportHeaderTemplateData), skips parts without text, reports progress and feeds the Markdown of
each part to an ExportWriter that the exporter creates per export - exporters are shared between users, so any state
must live in the writer. A part whose text has a Markdown heading is a chapter (chapterTitle, the same rule as the
Chapter Marker plugin: the first # line); before the first part the writer gets the list of all chapters in
contents(...) and every chapter start in message(markdown, chapter), so it can put an anchor (Chapter.anchor())
on the chapter and link to it from the table of contents (HTML, EPUB navigation, PDF, DOCX; TXT and Markdown ignore it).
The PDF exporter keeps the export and renders it again until the pages shown in the contents match the pages the
chapters landed on; DOCX uses PAGEREF fields and sets updateFields, so Word fills them in after asking.
Images attached to a part (ChatMessage.getImages()) are loaded once per part (ResourceService.findImage +
getResourceData, so only the exporting user's images; a missing one is skipped with a warning) as ExportImages (the bytes,
the type, the caption and where the image is: the effective anchor, whether the text flows around it, its width in percent). The
part is cut by PartLayout (domain.model, shared with the viewer: the text is cut at the boundaries between its top level
blocks and the images are put at the cuts their anchors ask for) into Parts, a TextPart (blocks of the text) or a
PicturePart (the images of one place, one anchor and wrapping) or a BandPart (images with text on both sides: the left text, the right text), and handed to the writer in message(List<Part>, Chapter).
The default of that method calls message(markdown, chapter) for the first text (an empty one when the part starts with images, so a
chapter starts where its part does), message(markdown, null) for the other text and images(List<ExportImage>) for each group,
(a band is the left text, the images and the right text), so a format that only writes things in order (TXT, Markdown, Obsidian, AO3) shows the images at their place in the text without
code of its own. A part with images but no text is exported with an empty text. HTML and EPUB override message(List<Part>, Chapter)
with toHtml(parts, source): <figure>s in a div.pictures (a row, aligned with pictures-left|center|right) or floats
(figure.float-left|float-right, a div.clear after the part) or a grid div.band (1fr n% 1fr: the text, div.band-middle with the figures, the text; one column on a narrow screen), the width as an inline width: n%, the rules in PICTURES_CSS;
HTML embeds the pictures as data URIs, EPUB as files in OEBPS/images/ with manifest items. PDF lays the parts out itself:
a picture is scaled to its width (no width: its size at 96 dpi, within the text), a row is a PdfPTable, and a picture the text
flows around is a PdfTemplate of the picture and its caption added with Image.TEXTWRAP (OpenPDF wraps only images); a band is a PdfPTable of three cells, the text of the two outer ones gathered by the sink into a list (emit) and left aligned. DOCX
writes a picture as a run (a paragraph aligned to the side), a row as a table without borders and a picture the text flows around as a
one cell table with a position (tblpPr, left, right or center: Word wraps the text around it, on both sides for the center, and the table can hold the captions); a band is that table in the center followed by the left and the right text. TXT writes
[Image: caption], Markdown  plus an italic caption line. figure(src, caption) renders the XHTML figure.
An exporter that has fonts and colors returns true from Exporter.supportsStyles() (HTML, EPUB, PDF, DOCX), which is what makes the dialog show
the Print style choice (PartStyleService.listForExport(): the styles with forExport, built in and the user's), preselecting ExportSettingsService.getStyleId(exporter) (the style the user set for the format in Settings → Exports, else Exporter.getDefaultStyleId()) and again when the format changes. The look each format had before styles are the built-in styles PartStyleRegistry.htmlExportStyle() (export-html), epubExportStyle(), pdfExportStyle() and docxExportStyle(), which the exporter uses when ExportOptions.style is null (the text part of the old STYLE constants moved there; what is left in them is the page, the title page, the contents and the figures). HTML and EPUB
add PartStyle.toPrintCss(withCustomCss) (the stylesheet worked out for paper: resolveForPrint takes the fallback of var(...) and the light
side of light-dark(...)) after their own, and wrap the text of the book in <div class="part-style-ID"><div class="chat-message-markdown">, so the
rules of the style (also drop caps, the width) apply as on the screen; EPUB leaves the custom CSS out (nested CSS). PDF and DOCX read PrintStyle.of(style)
(toPrintStyle()): points, RGB, the family of the stack (Liberation serif, sans and mono of the PDF; the named font in Word), indent, spacing, alignment and
the scene break; a style without content gives the defaults, which are the look these exporters had before styles.
MarkdownExporter is the only one that does not parse: the Markdown of each part is written as it is (raw HTML included, since the output is
source and not a rendered format), as blocks separated by an empty line.
It also provides toHtml (CommonMark, raw HTML escaped, XHTML safe) and walk, which turns
Markdown into paragraphs, headings, code and rules for formats built from styled text (BlockSink; used by TXT, DOCX
and PDF). Libraries: CommonMark (parsing), Apache POI (DOCX), OpenPDF with the Liberation fonts (PDF); EPUB is written
with java.util.zip.
Lorebooks and tags
Models
Function calls
The model can call Java methods (tool / function calling). The functions are the methods of an interface marked
@LLMFunction(name, description); their parameters may carry @LLMParam(name, description, required) (without it the
parameter is required and named as in the source, which needs the -parameters compiler option the project already
uses). The parameter types are anything Jackson reads: primitives, strings, enums, collections, maps, records and beans
(fields described with @JsonPropertyDescription).
Functions are asynchronous. Every @LLMFunction method returns void and takes a FunctionContinuation<R> as its
first parameter (hidden from the model, not in the schema); FunctionRegistry.definitionsOf throws
IllegalArgumentException for a method that returns a value or does not have the continuation first (or marks it with
@LLMParam). The method is called on a worker thread, in the request scope of the caller, and may hand the work to any
other thread; it finishes the call with done.complete(result) or done.fail(message | throwable), whenever and from
wherever. The first finish wins. done.isCancelled() tells that the request is over. An exception thrown by the method
itself is a failed call.
interface Lore {
@LLMFunction(description = "Finds lorebook entries by name")
void find(FunctionContinuation<List<String>> done, @LLMParam(description = "Part of the name") String name);
}
FunctionSet functions = inferenceServices.getFunctionRegistry().bind(Lore.class, new LoreImpl(user));
inferenceService.stream(payload, protocol, functions, callback);
OpenAICompatibleInferenceService offers the functions as tools, collects the calls from the stream (they arrive in
pieces), and when the answer ends with calls it runs them as a chain of futures: one after the other, each started on a worker thread (with the request attributes of the caller), the next only after the previous one answered through its continuation, and nothing blocks while a function waits. When the last has answered the next request is sent. Cancelling the token (or a dead callback) fails the pending call and ends the request with onCancel. onFunctionCall is called on the thread the function answered on.
appends an assistant message with the calls (LLMChatMessage.ofToolCalls, LLMToolCall) and a LLMRole.TOOL message
per result (ofToolResult) to its own copy of the payload and sends the request again. The callback sees one stream of
chunks and one completion (the text before a call and the answer after it are both RESPONSE chunks), and
InferenceAsyncCallback.onFunctionCall(call, result, failed) reports each call. A failing call is not an error of the
request: the model gets Error: <message> as the result and can correct itself. After MAX_FUNCTION_ROUNDS (10) rounds
the request is sent with tool_choice: none, so the stream ends with an answer. A cancelled token or a dead callback
stops before the next call. Every request of the loop is counted in the Usage separately. A payload that already holds
tool calls and results (LLMChatMessage with toolCalls / toolCallId) is sent as it is. The generation pipeline does
not offer functions yet; they are for services and extensions that call stream themselves. A service that cannot call
functions rejects a non-empty FunctionSet with UnsupportedOperationException (the default of the interface).
Users and settings
Agents
AgentService, AgentRunService, AgentMessageService, AgentRunner, AgentApprovalService, AgentAttentionService and AgentVirtualFileSystemService
(with CommandFunctions, the adapter that makes the commands tools of an agent) are described on their own page, Agents: the life of a run,
the services, the runner, the tools and the files of an agent.
A book an agent works on is locked: an attempt to change it (or its lorebooks) fails with ManuscriptLockedException /
LorebookLockedException, which name the agent; AgentLockCheck is the UI side of it. The commands the agent calls run in the working copy, so
they are not affected.
Permissions
PermissionService (domain.security.service) answers what an administrator allowed a user to do, see
Permission. checkPermissions(user, type) is false when the user has no
Permission of the type or it is not granted; getPermission(user, type) returns the object (null when there is none, a
deleted one does not count); getPermissions(user) lists them. The permission is found by its holder, not by its owner (the
owner is only the administrator who gave it), and the user is loaded again through UserService first - the session's user bean
is a scoped proxy with an id, login and name only (an unknown or deleted user has nothing). Administrators are not special here,
callers that let them do everything check AdminGuard.isAdmin() too (AIService.canCreate, HelpAssistantService). Changing
permissions is for administrators: save, saveWithoutEvent, delete and setGranted(user, type, granted) (changes the existing
permission or creates one owned by the current administrator) call AdminGuard.requireAdmin(). The migration that created the first ones
(PermissionsMigration) does not go through the service, it runs without a user.
The dialog behind the key button of the user list is ui.dialogs.permissions.PermissionsDialog (see UI); the
services it uses are PermissionService and SessionManager.reloadUser.
Login throttling
Failed logins are limited twice. Per user (User.failedLogins, lockedUntil, in the database) and per client address
(UserServiceImpl.addressFailures, in memory). Both use the same back-off, 1 s, 2 s, 4 s ... up to 15 minutes, after
10 (user) or 30 (address) consecutive failures; the address limit is higher because several users can share an address.
A failure counts against the address whatever the user name was, so a password spray over many accounts is stopped
even though no single account fails often.
The address comes from UIUtils.clientAddress(Configuration) (LoginCheckRoute.clientAddress() in the login routes),
which asks ClientAddressResolver:
-
The address of the connection is used by default.
X-Forwarded-Foris ignored, because any client can send it. -
When
trustedProxies(configuration.properties, or-DtrustedProxies=...) lists the connecting peer - addresses or CIDR ranges - the header is read from the right and the first address that is not a trusted proxy is the client. Entries left of it were sent by the client and are never used. The private network is not trusted by itself. -
Host names are never resolved and a bad
trustedProxiesentry stops the start.
Without a proxy nothing needs to be configured. Behind one, list the proxy, otherwise all users share its address and 30 failures from anyone block everybody for a while.
Administration
The services behind the Administration screen check on the server that the caller is an administrator, so they stay
safe if something other than that screen (an API, an extension) ever reaches them. They call
AdminGuard.requireAdmin() (bean in services-config.xml), which loads the current user from the database - the
session copy of admin is not trusted, a user demoted or deleted mid-session loses access at once - and throws
SecurityException for nobody, a deleted user or a non-administrator. Guarded: every DatabaseBackupService method
except createScheduledBackup and getNextScheduledBackup, CleanupService.getReferenceModel/analyze/purge, every ResourceGarbageService method,
OsgiService.installPackage/uninstallPackage, and UserService.deleteUser/clearPassword/unlock. Code that runs
without a request (the backup schedule, loading extensions on start, registerContributor) is not guarded, and
inside these services it calls private unguarded variants (listBackups, removeBackup). A new administrator-only
service method calls adminGuard.requireAdmin() first.
The manual
ManualService (ManualServiceImpl, a plain bean with init-method="load") holds the user manual in memory: the
Markdown pages of manual/user that the build packs as classpath:/manual/ (see
Packaging). It is global read-only data, so it has no user and no transaction.
load() reads every manual/**/*.md and keeps the pages, a missing or unreadable page is only logged. ManualParser
turns a page into a ManualPage: removes the front matter, pictures and Retype admonition markers (!!!warning Title
becomes a bold **Warning: Title** line), rewrites links between pages to paths in the manual
(books/story-editor.md#images, links into the developer manual become plain text) and splits the page by its second
and third level headings into ManualSections with the Retype anchors. ManualIndex is an immutable BM25 index over
the sections (heading and page title weigh more than the body, light English stemming, stop words dropped, no
support for Japanese or Chinese text). getPage tolerates a leading slash, an #anchor and a missing .md,
search(query, limit) returns ManualHits, best first.
The help assistant
HelpAssistantService is the model behind the help chat (ui.components.HelpAssistantWidget). getSettings / saveSettings are for
administrators (AdminGuard) and keep Settings(aiUuid, ...prompts, adminData) in AppSettings (what depends on the user is in their HELPER permission: Permission.helperApplyLimits and helperInteractions; an administrator without the permission has both off);
saveSettings accepts only an AI the administrator owns (IllegalArgumentException otherwise); requests are made without a protocol.
isAvailable() and stream(payload, functions, callback) are for administrators and the users with the permission PermissionType.HELPER
(target() checks it at every request). The
AI is loaded by uuid whoever owns it (the user could not see it), and only while it is not deleted and its owner is
still an administrator, which is checked at each request, so a demoted administrator's
AI is never handed to everybody. A request is metered (the normal path, AILimitService.check and
UsageService.record for the current user) only for a shared AI when the permission has helperApplyLimits; any other request runs in an
UnmeteredScope: a thread-local, opened by the caller around the call that starts the request, that an inference
service reads once when the request starts (before it moves to other threads) and then skips the limit check and
does not count the usage. OpenAICompatibleInferenceService and the google-genai and anthropic plugins do this; an extension AI
must too, see AI providers. The tokens of an unmetered
request are not counted anywhere: not for the user, not for the owner.
getInfo() tells any user which AI answers (Info(aiName, model, metered), null when unavailable). ask(history, uiState, callback) is what the chat calls: it puts the instructions in front of the conversation, binds HelpTools
and calls stream. HelpTools are ManualTools (search_manual over ManualService.search, read_manual_page over
getPage; read only, answers cut to a size a small model can use) and get_ui_state(path, deep), which asks the
UiStateSource (the page the question comes from; null when there is none, the function then fails with a message the
model can read), see Letting the help assistant see the UI. When the
the permission has helperInteractions and there is a UiStateSource,
ask binds InteractiveHelpTools instead (adds interact_with_ui(path, interactions), which calls UiStateSource.interact)
and appends HelpPrompts.INTERACTIVE to the instructions; see Interactions. HelpTools also has
read_last_errors(count) (what the user was shown that went wrong, read through UiStateSource.readProblems, see What went
wrong); the rules for it are HelpPrompts.PROBLEMS, added to every prompt after the one the
administrator may have edited. Manual tools print the address of what they found (URL: next to Page:):
ManualPage.url() / ManualSection.url() turn the path in the manual into the address on the published site
(ManualPage.WEB_BASE, https://enerccio.github.io/Marginalia/user/, the GitHub pages of the project: books/story-editor.md#images is
.../user/books/story-editor/#images; ManualServiceTest checks that every page has its file under docs/), and the default prompt tells the model to cite
pages as Markdown links with those addresses only; the widget opens them in a new tab.
The data of the Admin page. With Settings.adminData (AppSettings.helpAdminData, off by default, a checkbox of the Help assistant tab)
and an administrator asking, ask binds a second set of functions beside the others (FunctionSet.and), AdminHelpTools:
get_admin_data(topic) (extensions, users, backups, cleanup, application) and read_extension_log(extension, lines), with
HelpPrompts.ADMIN_DATA added to the instructions. They read through AdminDataSource, implemented by HelpAdminData: the extensions
with their state and verification (OsgiService, optional: it is not in the test context), the counts of the users (no names), the
database backups and schedule, what CleanupService.analyze() would purge (nothing is changed), memory and uptime, and the last lines
(40 by default, 200 at most, 6000 characters) of the log of an extension, found by its name, symbolic name or JAR (OsgiService.readLog).
The functions run on threads of their own, so HelpAdminData runs each read in the request of the administrator that asked
(ThreadCopyRequestAttributes taken in ask) and checks AdminGuard.requireAdmin() again. Log lines and verification reports go through
UiProblem.clean (cut, keys removed), nothing more: the setting exists because they can still carry text an extension wrote, on its way to
the AI of the assistant. The books of the user. With UserSetting.helpAssistantBooks (HelpAssistantService.isBooksAllowed / setBooksAllowed, a check box of the panel, off by default) ask appends HelpPrompts.BOOKS and binds a third set, BookHelpTools (list_folder, search_books, get_book_settings), implemented by BookHelpToolsImpl over BookDataSource / HelpBookData (FolderService, ManuscriptService, ChatMessageService, again in the request of the asking user). They return folder names, titles, descriptions and the short settings with their origin, prompts only as custom or built-in, and for a search where the words were found; never a text of a story. Lists are cut (100 entries, 20 matches). See Folders.
The instructions are one of two prompts, for administrators (AdminGuard.isAdmin()) or for the other users: the text in
AppSettings.helpPromptAdmin / helpPromptUser or, when that is null or blank, the default (HelpPrompts.ADMIN /
USER); saveSettings stores a blank prompt or one equal to the default as null, so a default follows the new version.
`` in a prompt is replaced by the list of pages (path, title, 110 characters of the description), a prompt
without it gets no index. The settings are installation wide, so a test that saves them must reset them first. It does not store anything. The conversation of the user is the list SessionPoint.getHelpChat()
(a synchronized List<LLMChatMessage>, user and assistant messages only), which lives and dies with the HTTP session
(logout invalidates it). A model without function calling fails the request; there is no fallback to a
prompt with searched sections.
Asynchronous operations
Long operations - generating a part, a summary, streaming from a model - return immediately and report through a callback:
CancellationToken.cancel() stops the operation (the inference callback's isDead() reports it to the stream). OpenAICompatibleInferenceService also acts on a stop at once while the server is still reading a long prompt and has sent nothing: openStream opens the stream on a virtual thread (inference-open) and the token's onCancel ends the wait with a CancellationException (the thread is interrupted, the connection dropped, a stream that opens later is closed unread), and a stop during the stream aborts the request (controller::abort) so a read that waits for the next chunk ends too; StreamStopTest has the cases.
Callbacks run on worker threads: UI code in them must use ui.access(...) (see
Threads and push), and service calls need the request scope shown above.
Writing a new service
- Interface in
domain/service/; for an entity extendExtendableService<Entity, EntityRepository>. - Implementation in
domain/service/impl/extendingExtendableServiceImpl<...>; repository extendingJpaExtendableRepository<Entity>withgetEntityClass(). -
Beans in
services-config.xml:<bean id="bookmarkRepository" class="com.github.enerccio.marginalia.domain.repository.impl.JpaBookmarkRepository"> <property name="entityManager" ref="em"/> </bean> <bean class="com.github.enerccio.marginalia.domain.service.impl.BookmarkServiceImpl"> <property name="repository" ref="bookmarkRepository" /> </bean> - Annotate every public method:
@CommonTx,@CommonTxReadOnlyor@NoTx. - Use
currentUserfor ownership,findForUser(uuid)for anything coming from outside, and callAdminGuard.requireAdmin()for administrator operations - hiding a button in the UI is not a permission check. - Keep the class non-final with public methods (CGLIB proxies subclass it).
- Test it on top of
MarginaliaTestBase(see Testing).
Extensions can't add beans to the application context. They keep their logic in their own classes annotated
@Configurable (plugins are compiled with the AspectJ compiler too), which get the application's services
@Autowired - for example the Reviewer's ReviewerService. Transactions then come from the application services they
call. See
Plugin development.