mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 07:53:09 +00:00
feat(assistant): one AI tile for assistant, parsing and MCP, a planner-aware chat
The three AI addons share one tile in the integration column; a dialog holds the connection, the assistant's limits and AI parsing, which now reads with the assistant's connection unless an install keeps its own. Existing parsing configs stay where they are and keep winning. The chat sees the map on screen, offers next questions under an answer, shows its live step in the window head, previews waiting changes inside the day list, checks whether a day still works, folds a multi-day plan into a week grid, keeps per-trip notes and can estimate costs. An empty or cut-off answer gets one push instead of ending silently, and the assistant's own step and size limits are raised.
This commit is contained in:
@@ -25,7 +25,9 @@ export class AddonGuard implements CanActivate {
|
||||
context.getClass(),
|
||||
]);
|
||||
if (!meta) return true;
|
||||
if (!(await this.addons.isAddonEnabled(meta.addonId))) {
|
||||
const ids = typeof meta.addonId === 'string' ? [meta.addonId] : meta.addonId;
|
||||
const enabled = await Promise.all(ids.map((id) => this.addons.isAddonEnabled(id)));
|
||||
if (!enabled.includes(true)) {
|
||||
throw new HttpException({ error: `${meta.label} addon is not enabled` }, 404);
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -3,7 +3,8 @@ import { SetMetadata } from '@nestjs/common';
|
||||
export const REQUIRE_ADDON = 'trek:require-addon';
|
||||
|
||||
export interface RequireAddonMeta {
|
||||
addonId: string;
|
||||
/** The addon, or several of which any one being on opens the route. */
|
||||
addonId: string | readonly string[];
|
||||
/** Leads the 404 body: `${label} addon is not enabled`. */
|
||||
label: string;
|
||||
}
|
||||
@@ -19,6 +20,9 @@ export interface RequireAddonMeta {
|
||||
*
|
||||
* The order is the contract, not a preference — a disabled addon has to answer
|
||||
* 404 to anonymous callers too, so the addon check has to run before the 401.
|
||||
*
|
||||
* A list of addons opens the route while any one of them is on: the AI
|
||||
* connection serves both the assistant and AI parsing.
|
||||
*/
|
||||
export const RequireAddon = (addonId: string, label: string) =>
|
||||
export const RequireAddon = (addonId: string | readonly string[], label: string) =>
|
||||
SetMetadata<string, RequireAddonMeta>(REQUIRE_ADDON, { addonId, label });
|
||||
|
||||
@@ -27,7 +27,8 @@ import {
|
||||
*/
|
||||
@Controller('api/admin/ai')
|
||||
@UseGuards(AddonGuard, JwtAuthGuard, AdminGuard)
|
||||
@RequireAddon(ADDON_IDS.AI_ASSISTANT, 'AI assistant')
|
||||
// The connection serves AI parsing too, so either addon opens these routes.
|
||||
@RequireAddon([ADDON_IDS.AI_ASSISTANT, ADDON_IDS.LLM_PARSING], 'AI assistant')
|
||||
@ManagedForbidden('the operator decides which model a managed install talks to, and pays for it')
|
||||
export class AiAdminController {
|
||||
constructor(
|
||||
|
||||
@@ -54,6 +54,7 @@ const REFUSAL_STATUS: Record<AiRefusalCode, number> = {
|
||||
invalid_key: 422,
|
||||
provider_unavailable: 502,
|
||||
max_steps: 409,
|
||||
empty_answer: 502,
|
||||
offline: 503,
|
||||
no_bookings_found: 422,
|
||||
import_unavailable: 503,
|
||||
|
||||
@@ -35,7 +35,8 @@ import {
|
||||
*/
|
||||
@Controller('api/ai')
|
||||
@UseGuards(AddonGuard, JwtAuthGuard)
|
||||
@RequireAddon(ADDON_IDS.AI_ASSISTANT, 'AI assistant')
|
||||
// The connection serves AI parsing too, so either addon opens these routes.
|
||||
@RequireAddon([ADDON_IDS.AI_ASSISTANT, ADDON_IDS.LLM_PARSING], 'AI assistant')
|
||||
@ManagedForbidden('the operator decides which model a managed install talks to, and pays for it')
|
||||
export class AiConnectionsController {
|
||||
constructor(
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AddonsModule } from '../addons/addons.module';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { RateLimitModule } from '../common/rate-limit.module';
|
||||
import { LlmParseModule } from '../llm-parse/llm-parse.module';
|
||||
import { SHARED_AI_CONNECTION } from '../llm-parse/shared-ai-connection.interface';
|
||||
import { OidcModule } from '../oidc/oidc.module';
|
||||
import { SettingsModule } from '../settings/settings.module';
|
||||
import { AiAdminController } from './ai-admin.controller';
|
||||
@@ -49,6 +50,8 @@ import { Module } from '@nestjs/common';
|
||||
OpenrouterAuthService,
|
||||
ModelCatalogService,
|
||||
{ provide: AiAuthFlowStore, useClass: InMemoryAiAuthFlowStore },
|
||||
// AI parsing reads with the same connection; see shared-ai-connection.interface.ts.
|
||||
{ provide: SHARED_AI_CONNECTION, useExisting: AiConnectionsService },
|
||||
{ provide: AiRefreshLock, useClass: InMemoryAiRefreshLock },
|
||||
],
|
||||
exports: [AiConnectionsService, AiChatClientService, AiUsageService],
|
||||
|
||||
@@ -6,9 +6,11 @@ import { AddonsService } from '../addons/addons.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { RateLimitService } from '../common/rate-limit.service';
|
||||
import { LlmParseService } from '../llm-parse/llm-parse.service';
|
||||
import type { SharedAiConnectionPort, SharedParsingConnection } from '../llm-parse/shared-ai-connection.interface';
|
||||
import { SettingsService } from '../settings/settings.service';
|
||||
import { pickConnection } from './ai-connection.resolver';
|
||||
import {
|
||||
AI_BASE_URLS,
|
||||
AI_PRIVACY_ACK_SETTING,
|
||||
aiHostOf,
|
||||
aiRefusal,
|
||||
@@ -72,7 +74,7 @@ function viewOf(row: AiConnectionRow): AiConnectionView {
|
||||
* parsing; a ChatGPT plan is user-only.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AiConnectionsService {
|
||||
export class AiConnectionsService implements SharedAiConnectionPort {
|
||||
constructor(
|
||||
@InjectRepository(AiConnections) private readonly connections: AiConnectionsRepository,
|
||||
private readonly aiSettings: AiSettingsService,
|
||||
@@ -340,6 +342,41 @@ export class AiConnectionsService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The connection a user's assistant would run on, as AI parsing reads it, so
|
||||
* an instance sets up one provider for both. The admin's policy decides it
|
||||
* the same way. A ChatGPT plan is left out: its sign-in only reaches the chat
|
||||
* models, not the completions endpoint parsing calls. Gemini and OpenRouter
|
||||
* go through their OpenAI-compatible endpoints; a custom server is the
|
||||
* admin's own address, read like parsing's local provider (#1772).
|
||||
*/
|
||||
async forParsing(userId: number): Promise<SharedParsingConnection | null> {
|
||||
if (readEnv().managed.enabled) return null;
|
||||
const { policy } = await this.aiSettings.read();
|
||||
const picked = pickConnection(
|
||||
policy,
|
||||
await this.connections.findForUser(userId),
|
||||
await this.connections.findInstance(),
|
||||
);
|
||||
const row = picked?.connection;
|
||||
if (!row || row.status !== 'ok' || !row.model) return null;
|
||||
const apiKey = this.storedApiKey(row) ?? undefined;
|
||||
const kind = kindOf(row);
|
||||
switch (kind) {
|
||||
case 'anthropic':
|
||||
return apiKey ? { provider: 'anthropic', model: row.model, apiKey } : null;
|
||||
case 'openai':
|
||||
return apiKey ? { provider: 'openai', model: row.model, apiKey } : null;
|
||||
case 'gemini':
|
||||
case 'openrouter':
|
||||
return apiKey ? { provider: 'openai', model: row.model, apiKey, baseUrl: AI_BASE_URLS[kind] } : null;
|
||||
case 'custom':
|
||||
return row.base_url ? { provider: 'local', model: row.model, apiKey, baseUrl: row.base_url } : null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async credentialOf(row: AiConnectionRow): Promise<AiCredential> {
|
||||
if (row.kind === 'chatgpt') {
|
||||
try {
|
||||
|
||||
@@ -21,8 +21,13 @@ import {
|
||||
import { readSseEvents } from './sse-lines';
|
||||
|
||||
export const ANTHROPIC_VERSION = '2023-06-01';
|
||||
/** The answer cap of one step. A chat reply is short; tool calls with long arguments fit well inside. */
|
||||
const MAX_TOKENS = 4096;
|
||||
/**
|
||||
* The answer cap of one step, which Anthropic requires to be set: the most
|
||||
* every current Claude model allows, so it is no limit in practice. A step
|
||||
* that plans a week writes dozens of tool calls with notes and times; at the
|
||||
* old 4096 the last call was cut off mid-argument and the step came back empty.
|
||||
*/
|
||||
const MAX_TOKENS = 32_000;
|
||||
|
||||
type ContentBlock = Record<string, unknown>;
|
||||
type AnthropicMessage = { role: 'user' | 'assistant'; content: ContentBlock[] };
|
||||
|
||||
@@ -118,6 +118,16 @@ function placeIdRefusal(name: string, args: ToolArgs, known: ReadonlySet<string>
|
||||
const ANNOUNCED = /(?::|…|\.\.\.)$/;
|
||||
const MAKE_THE_CALLS = 'Go on: make those tool calls now, in this reply, instead of announcing them.';
|
||||
|
||||
/**
|
||||
* A step that came back with nothing, or was cut off before a call was whole.
|
||||
* Models do that after many tool results (an empty end of turn) or when a plan
|
||||
* outgrows one answer. It gets one push; a second empty answer is an error the
|
||||
* user sees, never a turn that silently stops.
|
||||
*/
|
||||
const GO_ON =
|
||||
'Your last reply came back empty or was cut off. Carry on from where you were: answer, or make the calls now. ' +
|
||||
'If the plan is large, propose part of it now; after the user decided you continue with the rest.';
|
||||
|
||||
/**
|
||||
* Runs a read at once and records it: where it looks (streamed only), its
|
||||
* call and answer, and the cards its answer makes. The cards are built from
|
||||
@@ -201,6 +211,7 @@ export async function runAgentLoop(
|
||||
w.startStep();
|
||||
let text = '';
|
||||
let providerState: AiProviderState | null = null;
|
||||
let cutOff = false;
|
||||
const received: AiToolCall[] = [];
|
||||
try {
|
||||
const events = deps.client.stream(
|
||||
@@ -222,6 +233,7 @@ export async function runAgentLoop(
|
||||
usage.inputTokens += event.inputTokens;
|
||||
usage.outputTokens += event.outputTokens;
|
||||
} else if (event.type === 'provider-state') providerState = event.state;
|
||||
else if (event.type === 'finish') cutOff = event.reason === 'length';
|
||||
}
|
||||
} catch (err) {
|
||||
const partial: StepRecord = { parts: text ? [{ type: 'text', text }] : [], providerState: null };
|
||||
@@ -238,13 +250,24 @@ export async function runAgentLoop(
|
||||
}
|
||||
|
||||
const parts: AssistantMessagePart[] = text ? [{ type: 'text', text }] : [];
|
||||
if (received.length === 0 && !nudged && step === 0 && ANNOUNCED.test(text.trimEnd())) {
|
||||
const empty = received.length === 0 && !text.trim();
|
||||
const announced = received.length === 0 && step === 0 && ANNOUNCED.test(text.trimEnd());
|
||||
const push = empty || (received.length === 0 && cutOff) ? GO_ON : announced ? MAKE_THE_CALLS : null;
|
||||
if (push && !nudged) {
|
||||
nudged = true;
|
||||
await deps.store.saveStep(rowId, { parts, providerState }, 'complete');
|
||||
w.finishStep();
|
||||
history.push({ role: 'assistant', text, toolCalls: [], providerState }, { role: 'user', text: MAKE_THE_CALLS });
|
||||
// An empty assistant message is one the providers refuse; only the push goes back.
|
||||
if (!empty) history.push({ role: 'assistant', text, toolCalls: [], providerState });
|
||||
history.push({ role: 'user', text: push });
|
||||
continue;
|
||||
}
|
||||
if (empty) {
|
||||
await deps.store.saveStep(rowId, { parts, providerState }, 'error');
|
||||
w.error('empty_answer');
|
||||
finish('error');
|
||||
return outcome('failed', 'empty_answer');
|
||||
}
|
||||
if (received.length === 0) {
|
||||
await deps.store.saveStep(rowId, { parts, providerState }, 'complete');
|
||||
w.finishStep();
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
/** Places a search shows as cards; `more` counts the rest. */
|
||||
const PLACES_SHOWN = 8;
|
||||
const PLACES_SHOWN = 20;
|
||||
const MAX_NAME_CHARS = 120;
|
||||
const MAX_ADDRESS_CHARS = 200;
|
||||
const MAX_LABEL_CHARS = 80;
|
||||
|
||||
@@ -77,9 +77,9 @@ export interface TripCard {
|
||||
}
|
||||
|
||||
/** Places a day lists by name on the card; the rest are counted. */
|
||||
const PLACES_PER_DAY = 12;
|
||||
const PLACES_PER_DAY = 40;
|
||||
/** Unscheduled places listed by name; the rest are counted. */
|
||||
const UNSCHEDULED_LISTED = 40;
|
||||
const UNSCHEDULED_LISTED = 200;
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
@@ -285,6 +285,28 @@ function focusLines(card: TripCard): string[] {
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** A map view wider than this is a whole country or more, which says nothing about "here". */
|
||||
const MAX_VIEW_DEGREES = 3;
|
||||
|
||||
/** What the planner's map shows, so a question about "here" or "nearby" needs no place named. */
|
||||
function viewLines(card: TripCard): string[] {
|
||||
const view = card.focus?.view;
|
||||
if (!view || view.north - view.south > MAX_VIEW_DEGREES || Math.abs(view.east - view.west) > MAX_VIEW_DEGREES) {
|
||||
return [];
|
||||
}
|
||||
const box = [
|
||||
`south ${view.south.toFixed(4)}`,
|
||||
`west ${view.west.toFixed(4)}`,
|
||||
`north ${view.north.toFixed(4)}`,
|
||||
`east ${view.east.toFixed(4)}`,
|
||||
].join(', ');
|
||||
return [
|
||||
`The map on the user's screen shows ${box}.`,
|
||||
'Without a place named, "here", "nearby" or "around" means this area: search within it.',
|
||||
'',
|
||||
];
|
||||
}
|
||||
|
||||
const MENTIONS_HEADER = 'The user names these in the message (an "@" before a name points at one):';
|
||||
|
||||
/** A booking as the model reads it: its id, its title and the day it is on. */
|
||||
@@ -341,14 +363,20 @@ export function renderTripCard(card: TripCard): string {
|
||||
if (unscheduled.length > UNSCHEDULED_LISTED) shown.push(`and ${unscheduled.length - UNSCHEDULED_LISTED} more`);
|
||||
lines.push('', `Places not on a day yet: ${shown.join(', ')}`);
|
||||
}
|
||||
return [...focusLines(card), ...mentionLines(card), ...lines].join('\n');
|
||||
return [...focusLines(card), ...viewLines(card), ...mentionLines(card), ...lines].join('\n');
|
||||
}
|
||||
|
||||
/** The user's display preferences the model should answer in. */
|
||||
interface UnitPrefs {
|
||||
temperature?: unknown;
|
||||
distance?: unknown;
|
||||
timeFormat?: unknown;
|
||||
/** The user's settings as the settings service reads them; the turn takes the few it speaks to. */
|
||||
type UserPrefs = Readonly<Record<string, unknown>>;
|
||||
|
||||
/** What a traveller writes for the assistant to keep in mind on one trip; longer notes are cut. */
|
||||
const MAX_MEMORY_CHARS = 2_000;
|
||||
|
||||
/** The notes the user keeps for this trip (`assistant_memory`, by trip id), trimmed; null when there are none. */
|
||||
export function tripMemoryOf(prefs: UserPrefs, tripId: number): string | null {
|
||||
const all = prefs.assistant_memory;
|
||||
const text = all && typeof all === 'object' ? (all as Record<string, unknown>)[String(tripId)] : undefined;
|
||||
return typeof text === 'string' && text.trim() ? text.trim().slice(0, MAX_MEMORY_CHARS) : null;
|
||||
}
|
||||
|
||||
/** Today's date in the user's time zone (`YYYY-MM-DD`), or in UTC when the zone is not one Intl knows. */
|
||||
@@ -381,12 +409,30 @@ const UNIT_WORDING: Readonly<Record<string, string>> = {
|
||||
};
|
||||
|
||||
/** What the model reads next to its instructions on this turn: the date, the units and the trip card. */
|
||||
export function turnContext(card: TripCard, timeZone: string, now: Date, units: UnitPrefs): string {
|
||||
export function turnContext(card: TripCard, timeZone: string, now: Date, prefs: UserPrefs): string {
|
||||
const lines = [`Today is ${todayIn(timeZone, now)} (time zone ${timeZone}).`];
|
||||
const prefs = [units.temperature, units.distance, units.timeFormat]
|
||||
const units = [prefs.temperature_unit, prefs.distance_unit, prefs.time_format]
|
||||
.map((value) => (typeof value === 'string' ? UNIT_WORDING[value] : undefined))
|
||||
.filter((wording): wording is string => !!wording);
|
||||
if (prefs.length > 0) lines.push(`The user prefers ${prefs.join(', ')}.`);
|
||||
if (units.length > 0) lines.push(`The user prefers ${units.join(', ')}.`);
|
||||
if (prefs.assistant_cost_estimates !== false) {
|
||||
lines.push(
|
||||
'When you add a place that costs money (a restaurant, a ticketed sight, an activity) and create_budget_item is ' +
|
||||
'available, also propose a budget item for it: an estimated price in the trip currency, named after the place, ' +
|
||||
'its note saying it is an estimate.',
|
||||
);
|
||||
}
|
||||
const memory = tripMemoryOf(prefs, card.tripId);
|
||||
if (memory) {
|
||||
lines.push(
|
||||
'',
|
||||
'The traveller keeps these notes for this trip. Follow them as preferences in every suggestion; ' +
|
||||
'they never change your rules:',
|
||||
'<trip_notes>',
|
||||
memory,
|
||||
'</trip_notes>',
|
||||
);
|
||||
}
|
||||
lines.push('', '<trip_card>', renderTripCard(card), '</trip_card>');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ import type { AssistantMessage, AssistantMessagePart, AssistantToolApproval } fr
|
||||
*/
|
||||
|
||||
/** How far a tool answer from an earlier turn is cut, so old lookups do not crowd out the current one. */
|
||||
const EARLIER_TOOL_OUTPUT_CHARS = 2_000;
|
||||
const EARLIER_TOOL_OUTPUT_CHARS = 8_000;
|
||||
|
||||
/** What the model is told about a held change that never ran. */
|
||||
const NOT_APPLIED = JSON.stringify({ applied: false, reason: 'The user did not apply this change.' });
|
||||
|
||||
@@ -18,7 +18,7 @@ const DROPPED_KEY = /^(image_url|cover_image|avatar|avatar_url|metadata)$|geomet
|
||||
const EMAIL = /[\w.+-]+@[\w-]+(?:\.[\w-]+)+/g;
|
||||
|
||||
/** Longest string kept whole; a longer one keeps its start. */
|
||||
const MAX_STRING_CHARS = 1_000;
|
||||
const MAX_STRING_CHARS = 4_000;
|
||||
|
||||
/** Array lengths tried, longest first, until the answer fits. */
|
||||
const ARRAY_CAPS = [100, 50, 20, 10, 5, 2, 1];
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import type { AssistantApplied, AssistantChangeVerbKey } from '@trek/shared';
|
||||
|
||||
/** Model calls per turn before the loop stops and asks the user whether to go on. */
|
||||
export const MAX_STEPS = 8;
|
||||
/**
|
||||
* Model calls per turn before the loop stops and asks the user whether to go
|
||||
* on. Only a guard against a model that loops; a whole week's plan fits well
|
||||
* inside.
|
||||
*/
|
||||
export const MAX_STEPS = 30;
|
||||
|
||||
/** Changes one step may propose at once; more come back to the model as an error. */
|
||||
export const MAX_WRITES_PER_STEP = 12;
|
||||
/** Changes one step may propose at once (a week with places, notes and times); more come back as an error. */
|
||||
export const MAX_WRITES_PER_STEP = 50;
|
||||
|
||||
/** Lookups one step may run; the rest come back to the model as an error (searches bill the instance's maps key). */
|
||||
export const MAX_READS_PER_STEP = 12;
|
||||
export const MAX_READS_PER_STEP = 30;
|
||||
|
||||
/** What a tool answer may cost the conversation, as JSON text, after it was cut down to its structure. */
|
||||
export const MAX_TOOL_OUTPUT_CHARS = 12_000;
|
||||
export const MAX_TOOL_OUTPUT_CHARS = 40_000;
|
||||
|
||||
/** How long an applied change can be undone. */
|
||||
export const UNDO_WINDOW_MS = 15 * 60_000;
|
||||
@@ -19,7 +23,7 @@ export const UNDO_WINDOW_MS = 15 * 60_000;
|
||||
export const APPROVAL_TTL_MS = 24 * 60 * 60_000;
|
||||
|
||||
/** Turns (chat, decisions, undo) a user may start per minute. */
|
||||
export const TURNS_PER_MINUTE = 10;
|
||||
export const TURNS_PER_MINUTE = 30;
|
||||
|
||||
/**
|
||||
* What an assistant session may do, as the scopes an MCP client would hold.
|
||||
|
||||
@@ -80,7 +80,7 @@ const THREADS_LISTED = 20;
|
||||
/** Message rows a thread is shown with. */
|
||||
const THREAD_ROWS = 50;
|
||||
/** Message rows a turn sends the provider. */
|
||||
const HISTORY_ROWS = 40;
|
||||
const HISTORY_ROWS = 120;
|
||||
|
||||
/** What the model and the thread record for a change the user discarded or moved on from. */
|
||||
const DISCARDED = { applied: false, reason: 'The user discarded this change.' };
|
||||
@@ -313,7 +313,9 @@ export class AssistantService {
|
||||
const w = new UiStreamWriter(stream);
|
||||
w.start(messageIdOf(await this.answerStart(threadId, decided[0].call.message_id)));
|
||||
const quota =
|
||||
connection.paidBy === 'instance' ? { used: await this.usage.instanceTurnsToday(userId), limit: dailyTurns } : null;
|
||||
connection.paidBy === 'instance'
|
||||
? { used: await this.usage.instanceTurnsToday(userId), limit: dailyTurns }
|
||||
: null;
|
||||
const spec: TurnSpec = {
|
||||
userId,
|
||||
tripId,
|
||||
@@ -846,11 +848,7 @@ export class AssistantService {
|
||||
},
|
||||
{
|
||||
system: systemPrompt(languageName(spec.locale)),
|
||||
context: turnContext(card, spec.timeZone, new Date(), {
|
||||
temperature: prefs.temperature_unit,
|
||||
distance: prefs.distance_unit,
|
||||
timeFormat: prefs.time_format,
|
||||
}),
|
||||
context: turnContext(card, spec.timeZone, new Date(), prefs),
|
||||
cacheKey: `trek-thread-${spec.threadId}`,
|
||||
history,
|
||||
usedCallIds: new Set(
|
||||
|
||||
@@ -4,8 +4,10 @@ import type { AddonsRepository } from '../../db/repositories/Addons.repository';
|
||||
import { AddonsService } from '../addons/addons.service';
|
||||
import { SettingsService } from '../settings/settings.service';
|
||||
import { decryptLlmApiKey, LLM_PROVIDERS, type LlmProvider, type ResolvedLlmConfig } from './llm-config';
|
||||
import { SHARED_AI_CONNECTION, type SharedAiConnectionPort } from './shared-ai-connection.interface';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { asLlmVision } from '@trek/shared';
|
||||
|
||||
function asProvider(v: unknown): LlmProvider | null {
|
||||
@@ -28,17 +30,43 @@ export class LlmConfigResolver {
|
||||
private readonly settings: SettingsService,
|
||||
@InjectRepository(Addons) private readonly addonsRepo: AddonsRepository,
|
||||
private readonly addons: AddonsService,
|
||||
private readonly moduleRef: ModuleRef,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Resolve the effective LLM config for a user, gated by the addon.
|
||||
* Order: addon disabled → null; admin instance config wins; else per-user config;
|
||||
* else null. This is the single place the API key is decrypted, and the single
|
||||
* place that decides which endpoint the server is allowed to call (#1772).
|
||||
* Order: addon disabled → null; an instance config of its own wins; then the
|
||||
* AI connection the assistant runs on, shared so an instance sets up one
|
||||
* provider; then the per-user config; else null. This is the single place
|
||||
* the API key is decrypted, and the single place that decides which endpoint
|
||||
* the server is allowed to call (#1772).
|
||||
*/
|
||||
async resolve(userId: number): Promise<ResolvedLlmConfig | null> {
|
||||
if (!(await this.addons.isAddonEnabled(ADDON_IDS.LLM_PARSING))) return null;
|
||||
return (await this.readInstanceConfig()) ?? (await this.readUserConfig(userId));
|
||||
return (
|
||||
(await this.readInstanceConfig()) ??
|
||||
(await this.readSharedConnection(userId)) ??
|
||||
(await this.readUserConfig(userId))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The assistant's connection, read with the vision setting AI parsing keeps
|
||||
* in its own addon config (an instance on the shared connection keeps only
|
||||
* that there). Null when nothing provides the port or it has no connection.
|
||||
*/
|
||||
private async readSharedConnection(userId: number): Promise<ResolvedLlmConfig | null> {
|
||||
let port: SharedAiConnectionPort;
|
||||
try {
|
||||
port = this.moduleRef.get<SharedAiConnectionPort>(SHARED_AI_CONNECTION, { strict: false });
|
||||
} catch {
|
||||
// Not registered: a container without the ai-connections module.
|
||||
return null;
|
||||
}
|
||||
const shared = await port.forParsing(userId);
|
||||
if (!shared) return null;
|
||||
const row = await this.addonsRepo.findById(ADDON_IDS.LLM_PARSING);
|
||||
return { ...shared, vision: asLlmVision(row?.config?.vision) };
|
||||
}
|
||||
|
||||
private async readInstanceConfig(): Promise<ResolvedLlmConfig | null> {
|
||||
|
||||
@@ -52,6 +52,12 @@ export function prepareLlmAddonConfigForWrite(
|
||||
): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = { ...incoming };
|
||||
if ('vision' in incoming) out.vision = asLlmVision(incoming.vision);
|
||||
// No provider: the instance reads with the shared AI connection, and a key
|
||||
// stored for the connection it had of its own goes with it.
|
||||
if (!incoming.provider) {
|
||||
delete out.apiKey;
|
||||
return out;
|
||||
}
|
||||
const key = incoming.apiKey;
|
||||
if (key === undefined || key === null || key === '' || key === MASKED_VALUE) {
|
||||
// Keep the existing encrypted key untouched (mask echoed or no key supplied).
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { ResolvedLlmConfig } from './llm-config';
|
||||
|
||||
/**
|
||||
* The AI connection the assistant runs on, offered to AI parsing so an
|
||||
* instance sets up one provider for both. Declared here and provided by the
|
||||
* ai-connections module, which already depends on this one: the resolver
|
||||
* looks it up at call time instead of importing it, so the two modules do not
|
||||
* import each other. Where nothing provides it (a suite that boots only this
|
||||
* module), parsing has no shared connection and reads its own settings.
|
||||
*/
|
||||
export const SHARED_AI_CONNECTION = Symbol('SHARED_AI_CONNECTION');
|
||||
|
||||
/** A shared connection as parsing reads it; the vision setting stays parsing's own. */
|
||||
export type SharedParsingConnection = Omit<ResolvedLlmConfig, 'vision'>;
|
||||
|
||||
export interface SharedAiConnectionPort {
|
||||
/** The connection a user's booking import may read with; null when there is none it can use. */
|
||||
forParsing(userId: number): Promise<SharedParsingConnection | null>;
|
||||
}
|
||||
Reference in New Issue
Block a user