From 967abed4f94733f011416fd258612730d4716cc7 Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Wed, 29 Jul 2026 14:53:37 +0200 Subject: [PATCH] LLM: provider-agnostic code (not finished); styles have to be in sylesheets (not finished) --- .../llm/app/src/LLMAdminController.mjs | 798 ++++----- .../modules/llm/app/src/LLMChatController.mjs | 184 +- .../llm/app/src/LLMComplianceController.mjs | 1 + .../web/modules/llm/app/src/LLMErrors.mjs | 6 + .../llm/app/src/LLMProviderFactory.mjs | 13 + .../app/src/providers/AnthropicProvider.mjs | 103 ++ .../llm/app/src/providers/BaseProvider.mjs | 67 + .../llm/app/src/providers/OpenAIProvider.mjs | 70 + .../llm/app/views/llm-admin-settings.pug | 2 + .../js/components/llm-admin-settings-page.tsx | 1546 +++++++++-------- .../frontend/stylesheets/llm-settings.scss | 107 ++ 11 files changed, 1589 insertions(+), 1308 deletions(-) create mode 100644 services/web/modules/llm/app/src/LLMErrors.mjs create mode 100644 services/web/modules/llm/app/src/LLMProviderFactory.mjs create mode 100644 services/web/modules/llm/app/src/providers/AnthropicProvider.mjs create mode 100644 services/web/modules/llm/app/src/providers/BaseProvider.mjs create mode 100644 services/web/modules/llm/app/src/providers/OpenAIProvider.mjs create mode 100644 services/web/modules/llm/frontend/stylesheets/llm-settings.scss diff --git a/services/web/modules/llm/app/src/LLMAdminController.mjs b/services/web/modules/llm/app/src/LLMAdminController.mjs index 6f967aed4d..19d3c6832b 100644 --- a/services/web/modules/llm/app/src/LLMAdminController.mjs +++ b/services/web/modules/llm/app/src/LLMAdminController.mjs @@ -1,48 +1,50 @@ import logger from '@overleaf/logger' -import { promises as fs } from 'fs' +import fs from 'fs' import path from 'path' +import { z } from 'zod' import { expressify } from '@overleaf/promise-utils' import { encryptSecret, decryptSecret } from './LLMCrypto.mjs' // overleaf-lab: at-rest encryption of admin API key +import { createLLMProvider } from './LLMProviderFactory.mjs' import { - DEFAULT_ASK_AI_SYSTEM_PROMPT, - DEFAULT_ERROR_PROMPT, - DEFAULT_REVIEW_SYSTEM_PROMPT, - DEFAULT_ASK_AI_ACTION_PROMPTS, - mergeActionPrompts, + DEFAULT_ASK_AI_SYSTEM_PROMPT, + DEFAULT_ERROR_PROMPT, + DEFAULT_REVIEW_SYSTEM_PROMPT, + DEFAULT_ASK_AI_ACTION_PROMPTS, + mergeActionPrompts, } from './LLMPrompts.mjs' // overleaf-lab: editable prompt defaults + merge helper // Persist admin LLM settings in the same volume used by Overleaf data const ADMIN_SETTINGS_PATH = process.env.LLM_ADMIN_SETTINGS_PATH || - '/var/lib/overleaf/data/llm-admin-settings.json' + '/var/lib/overleaf/data/llm-admin-settings.json' // overleaf-lab: fallback for the review answer budget when the admin has not set one. // Mirrors LLMComplianceController's REVIEW_MAX_TOKENS default (env override, else // 12000). Duplicated here on purpose: importing it would make the two controllers // import each other, since the compliance one already imports this module. const DEFAULT_REVIEW_MAX_TOKENS = - Number.parseInt(process.env.LLM_REVIEW_MAX_TOKENS, 10) > 0 - ? Number.parseInt(process.env.LLM_REVIEW_MAX_TOKENS, 10) - : 12000 + Number.parseInt(process.env.LLM_REVIEW_MAX_TOKENS, 10) > 0 + ? Number.parseInt(process.env.LLM_REVIEW_MAX_TOKENS, 10) + : 12000 -async function readAdminSettings() { - try { - const raw = await fs.readFile(ADMIN_SETTINGS_PATH, 'utf8') - return JSON.parse(raw) - } catch (err) { - if (err.code === 'ENOENT') return {} - logger.warn({ err, path: ADMIN_SETTINGS_PATH }, '[LLM] Could not read admin settings file') - return {} - } +function readAdminSettings() { + try { + const raw = fs.readFileSync(ADMIN_SETTINGS_PATH, 'utf8') + return JSON.parse(raw) + } catch (err) { + if (err.code === 'ENOENT') return {} + logger.warn({ err, path: ADMIN_SETTINGS_PATH }, '[LLM] Could not read admin settings file') + return {} + } } -async function writeAdminSettings(data) { - try { - await fs.mkdir(path.dirname(ADMIN_SETTINGS_PATH), { recursive: true }) - await fs.writeFile(ADMIN_SETTINGS_PATH, JSON.stringify(data, null, 2), 'utf8') - } catch (err) { - logger.error({ err, path: ADMIN_SETTINGS_PATH }, '[LLM] Could not write admin settings file') - throw err - } +function writeAdminSettings(data) { + try { + fs.mkdirSync(path.dirname(ADMIN_SETTINGS_PATH), { recursive: true }) + fs.writeFileSync(ADMIN_SETTINGS_PATH, JSON.stringify(data, null, 2), { mode: 0o600 }) + } catch (err) { + logger.error({ err, path: ADMIN_SETTINGS_PATH }, '[LLM] Could not write admin settings file') + throw err + } } // overleaf-lab: the shared LLM backend can be configured either via this admin @@ -51,322 +53,310 @@ async function writeAdminSettings(data) { // here so the admin page and the model scan reflect an env-only configuration // instead of looking empty. function envModelList() { - const raw = process.env.LLM_AVAILABLE_MODELS || process.env.LLM_MODEL_NAME || '' - return raw - .split(',') - .map(m => m.trim()) - .filter(m => m.length > 0) + const raw = process.env.LLM_AVAILABLE_MODELS || process.env.LLM_MODEL_NAME || '' + return raw + .split(',') + .map(m => m.trim()) + .filter(m => m.length > 0) } // Effective settings for display: the JSON value, else the env fallback, plus // flags telling the UI which values are inherited from the environment. The API // key value is never returned, only whether one is set. async function buildDisplaySettings() { - const settings = await readAdminSettings() - const envModels = envModelList() - const jsonHasModels = - Array.isArray(settings.allowedModels) && settings.allowedModels.length > 0 - return { - systemPrompt: settings.systemPrompt || '', - llmApiUrl: settings.llmApiUrl || process.env.LLM_API_URL || '', - hasLlmApiKey: !!(settings.llmApiKey || process.env.LLM_API_KEY), - allowedModels: jsonHasModels ? settings.allowedModels : envModels, - completionModel: settings.completionModel || '', - llmApiUrlFromEnv: !settings.llmApiUrl && !!process.env.LLM_API_URL, - hasApiKeyFromEnv: !settings.llmApiKey && !!process.env.LLM_API_KEY, - allowedModelsFromEnv: !jsonHasModels && envModels.length > 0, - // overleaf-lab: document compliance review settings - complianceRubrics: Array.isArray(settings.complianceRubrics) ? settings.complianceRubrics : [], - reviewModel: settings.reviewModel || '', - maxContextTokens: settings.maxContextTokens || 32000, - reviewMaxTokens: settings.reviewMaxTokens || DEFAULT_REVIEW_MAX_TOKENS, - // overleaf-lab: per-feature enable flags; absent field defaults to true so - // existing installs keep every feature on. - chatEnabled: settings.chatEnabled !== false, - completionEnabled: settings.completionEnabled !== false, - reviewEnabled: settings.reviewEnabled !== false, - // overleaf-lab: editable prompt overrides. Show the EFFECTIVE value (the - // admin override when set, else the shipped default) plus the pristine - // defaults so the admin page can offer a reset-to-default button. - askAiSystemPrompt: settings.askAiSystemPrompt || DEFAULT_ASK_AI_SYSTEM_PROMPT, - errorPrompt: settings.errorPrompt || DEFAULT_ERROR_PROMPT, - reviewSystemPrompt: settings.reviewSystemPrompt || DEFAULT_REVIEW_SYSTEM_PROMPT, - askAiActionPrompts: mergeActionPrompts(settings.askAiActionPrompts), - promptDefaults: { - askAiSystemPrompt: DEFAULT_ASK_AI_SYSTEM_PROMPT, - errorPrompt: DEFAULT_ERROR_PROMPT, - reviewSystemPrompt: DEFAULT_REVIEW_SYSTEM_PROMPT, - askAiActionPrompts: DEFAULT_ASK_AI_ACTION_PROMPTS, - }, - } + const settings = readAdminSettings() + const envModels = envModelList() + const jsonHasModels = + Array.isArray(settings.allowedModels) && settings.allowedModels.length > 0 + return { + systemPrompt: settings.systemPrompt || '', + llmApiUrl: settings.llmApiUrl || process.env.LLM_API_URL || '', + llmApiType: settings.llmApiType || process.env.LLM_API_TYPE || 'anthropic', + hasLlmApiKey: !!(settings.llmApiKey || process.env.LLM_API_KEY), + allowedModels: jsonHasModels ? settings.allowedModels : envModels, + completionModel: settings.completionModel || '', + llmApiUrlFromEnv: !settings.llmApiUrl && !!process.env.LLM_API_URL, + llmApiTypeFromEnv: !settings.llmApiType && !!process.env.LLM_API_TYPE, + hasApiKeyFromEnv: !settings.llmApiKey && !!process.env.LLM_API_KEY, + allowedModelsFromEnv: !jsonHasModels && envModels.length > 0, + // overleaf-lab: document compliance review settings + complianceRubrics: Array.isArray(settings.complianceRubrics) ? settings.complianceRubrics : [], + reviewModel: settings.reviewModel || '', + maxContextTokens: settings.maxContextTokens || 32000, + reviewMaxTokens: settings.reviewMaxTokens || DEFAULT_REVIEW_MAX_TOKENS, + // overleaf-lab: per-feature enable flags; absent field defaults to true so + // existing installs keep every feature on. + chatEnabled: settings.chatEnabled !== false, + completionEnabled: settings.completionEnabled !== false, + reviewEnabled: settings.reviewEnabled !== false, + // overleaf-lab: editable prompt overrides. Show the EFFECTIVE value (the + // admin override when set, else the shipped default) plus the pristine + // defaults so the admin page can offer a reset-to-default button. + askAiSystemPrompt: settings.askAiSystemPrompt || DEFAULT_ASK_AI_SYSTEM_PROMPT, + errorPrompt: settings.errorPrompt || DEFAULT_ERROR_PROMPT, + reviewSystemPrompt: settings.reviewSystemPrompt || DEFAULT_REVIEW_SYSTEM_PROMPT, + askAiActionPrompts: mergeActionPrompts(settings.askAiActionPrompts), + promptDefaults: { + askAiSystemPrompt: DEFAULT_ASK_AI_SYSTEM_PROMPT, + errorPrompt: DEFAULT_ERROR_PROMPT, + reviewSystemPrompt: DEFAULT_REVIEW_SYSTEM_PROMPT, + askAiActionPrompts: DEFAULT_ASK_AI_ACTION_PROMPTS, + }, + } } async function adminSettingsPage(req, res) { - const pugPath = new URL('../../app/views/llm-admin-settings.pug', import.meta.url).pathname - res.render(pugPath, await buildDisplaySettings()) + const pugPath = new URL('../../app/views/llm-admin-settings.pug', import.meta.url).pathname + res.render(pugPath, await buildDisplaySettings()) } async function getAdminSettings(req, res) { - res.json(await buildDisplaySettings()) + res.json(await buildDisplaySettings()) } +const llmSettingsSchema = z.object({ + systemPrompt: z.string().max(4000), + + llmApiUrl: z.string().optional(), + llmApiType: z.string().optional(), + llmApiKey: z.string().optional(), + + allowedModels: z.array(z.string()).optional(), + + completionModel: z.string().optional(), + + complianceRubrics: z.array(z.unknown()).optional(), + + reviewModel: z.string().optional(), + + maxContextTokens: z.number().optional(), + reviewMaxTokens: z.number().optional(), + + chatEnabled: z.boolean().optional(), + completionEnabled: z.boolean().optional(), + reviewEnabled: z.boolean().optional(), + + // overleaf-lab: editable prompt overrides. Each scalar prompt, when provided, + // must be a string capped at 8000 chars. An empty string is allowed and means + // "fall back to default" (buildDisplaySettings/getLLMPrompts use `|| DEFAULT`). + askAiSystemPrompt: z.string().max(8000).optional(), + errorPrompt: z.string().max(8000).optional(), + reviewSystemPrompt: z.string().max(8000).optional(), + + // overleaf-lab: action prompts, when provided, must be a plain (non-array) object. + askAiActionPrompts: z + .record(z.string(), z.string()) + .optional(), +}) + async function saveAdminSettings(req, res) { - const { - systemPrompt, - llmApiUrl, - llmApiKey, - allowedModels, - completionModel, - complianceRubrics, - reviewModel, - maxContextTokens, - reviewMaxTokens, - chatEnabled, - completionEnabled, - reviewEnabled, - // overleaf-lab: editable prompt overrides. - askAiSystemPrompt, - errorPrompt, - reviewSystemPrompt, - askAiActionPrompts, - } = req.body + const safeBody = llmSettingsSchema.safeParse(req.body) + if (!safeBody.success) { + const errors = Object.fromEntries( + safeBody.error.issues.map(issue => [ + issue.path.join('.'), + issue.message, + ]) + ) + logger.error( errors, 'Bad admin settings') +// TODO: send all errors to the frontend and show them + return res.status(400).json({ + error: safeBody.error.issues[0].message, + }) + } - if (typeof systemPrompt !== 'string') { - return res.status(400).json({ error: 'systemPrompt must be a string' }) - } - if (systemPrompt.length > 4000) { - return res.status(400).json({ error: 'systemPrompt must be 4000 characters or fewer' }) - } + const { + systemPrompt, + llmApiUrl, + llmApiType, + llmApiKey, + allowedModels, + completionModel, + complianceRubrics, + reviewModel, + maxContextTokens, + reviewMaxTokens, + chatEnabled, + completionEnabled, + reviewEnabled, + // overleaf-lab: editable prompt overrides. + askAiSystemPrompt, + errorPrompt, + reviewSystemPrompt, + askAiActionPrompts, + } = safeBody.data - if (llmApiUrl && typeof llmApiUrl !== 'string') { - return res.status(400).json({ error: 'llmApiUrl must be a string' }) - } - if (llmApiKey && typeof llmApiKey !== 'string') { - return res.status(400).json({ error: 'llmApiKey must be a string' }) - } - if (allowedModels && !Array.isArray(allowedModels)) { - return res.status(400).json({ error: 'allowedModels must be an array' }) - } + const existing = readAdminSettings() - // overleaf-lab: validate the document compliance review settings. - if (complianceRubrics !== undefined && !Array.isArray(complianceRubrics)) { - return res.status(400).json({ error: 'complianceRubrics must be an array' }) - } - if (reviewModel !== undefined && typeof reviewModel !== 'string') { - return res.status(400).json({ error: 'reviewModel must be a string' }) - } + // overleaf-lab: sanitize each rubric and cap the count. Entries without an id or + // name are dropped; text fields are length-capped. When not provided, keep the + // existing rubrics untouched. + let sanitizedRubrics + if (Array.isArray(complianceRubrics)) { + sanitizedRubrics = complianceRubrics + .map(r => ({ + id: String((r && r.id) || ''), + name: String((r && r.name) || '').slice(0, 200), + guidelines: String((r && r.guidelines) || '').slice(0, 20000), + // overleaf-lab: per-rubric mechanical scans ("Label :: regex" per + // line); policy lives with the rubric it verifies, never in code. + scanPatterns: String((r && r.scanPatterns) || '').slice(0, 4000), + })) + .filter(r => r.id && r.name) + .slice(0, 50) + } else { + sanitizedRubrics = Array.isArray(existing.complianceRubrics) ? existing.complianceRubrics : [] + } - // overleaf-lab: per-feature enable flags are optional booleans. When provided - // they must be booleans; when omitted the existing value is preserved below. - if (chatEnabled !== undefined && typeof chatEnabled !== 'boolean') { - return res.status(400).json({ error: 'chatEnabled must be a boolean' }) - } - if (completionEnabled !== undefined && typeof completionEnabled !== 'boolean') { - return res.status(400).json({ error: 'completionEnabled must be a boolean' }) - } - if (reviewEnabled !== undefined && typeof reviewEnabled !== 'boolean') { - return res.status(400).json({ error: 'reviewEnabled must be a boolean' }) - } + // overleaf-lab: clamp the context window to a sane range; keep existing (or the + // 32000 default) when not provided. + let sanitizedMaxContextTokens + if (maxContextTokens !== undefined) { + const parsed = parseInt(maxContextTokens, 10) + sanitizedMaxContextTokens = Number.isNaN(parsed) + ? existing.maxContextTokens || 32000 + : Math.min(1000000, Math.max(2000, parsed)) + } else { + sanitizedMaxContextTokens = existing.maxContextTokens || 32000 + } - // overleaf-lab: editable prompt overrides. Each scalar prompt, when provided, - // must be a string capped at 8000 chars. An empty string is allowed and means - // "fall back to default" (buildDisplaySettings/getLLMPrompts use `|| DEFAULT`). - if (askAiSystemPrompt !== undefined && typeof askAiSystemPrompt !== 'string') { - return res.status(400).json({ error: 'askAiSystemPrompt must be a string' }) - } - if (typeof askAiSystemPrompt === 'string' && askAiSystemPrompt.length > 8000) { - return res.status(400).json({ error: 'askAiSystemPrompt must be 8000 characters or fewer' }) - } - if (errorPrompt !== undefined && typeof errorPrompt !== 'string') { - return res.status(400).json({ error: 'errorPrompt must be a string' }) - } - if (typeof errorPrompt === 'string' && errorPrompt.length > 8000) { - return res.status(400).json({ error: 'errorPrompt must be 8000 characters or fewer' }) - } - if (reviewSystemPrompt !== undefined && typeof reviewSystemPrompt !== 'string') { - return res.status(400).json({ error: 'reviewSystemPrompt must be a string' }) - } - if (typeof reviewSystemPrompt === 'string' && reviewSystemPrompt.length > 8000) { - return res.status(400).json({ error: 'reviewSystemPrompt must be 8000 characters or fewer' }) - } - // overleaf-lab: action prompts, when provided, must be a plain (non-array) object. - if ( - askAiActionPrompts !== undefined && - (typeof askAiActionPrompts !== 'object' || - askAiActionPrompts === null || - Array.isArray(askAiActionPrompts)) - ) { - return res.status(400).json({ error: 'askAiActionPrompts must be an object' }) - } + // overleaf-lab: clamp the review answer budget. This is the model's max_tokens for + // the report AND the room reserved for it in the context check, so it is bounded + // well below any real context window. + let sanitizedReviewMaxTokens + if (reviewMaxTokens !== undefined) { + const parsed = parseInt(reviewMaxTokens, 10) + sanitizedReviewMaxTokens = Number.isNaN(parsed) + ? existing.reviewMaxTokens || DEFAULT_REVIEW_MAX_TOKENS + : Math.min(128000, Math.max(500, parsed)) + } else { + sanitizedReviewMaxTokens = existing.reviewMaxTokens || DEFAULT_REVIEW_MAX_TOKENS + } - const existing = await readAdminSettings() - - // overleaf-lab: sanitize each rubric and cap the count. Entries without an id or - // name are dropped; text fields are length-capped. When not provided, keep the - // existing rubrics untouched. - let sanitizedRubrics - if (Array.isArray(complianceRubrics)) { - sanitizedRubrics = complianceRubrics - .map(r => ({ - id: String((r && r.id) || ''), - name: String((r && r.name) || '').slice(0, 200), - guidelines: String((r && r.guidelines) || '').slice(0, 20000), - // overleaf-lab: per-rubric mechanical scans ("Label :: regex" per - // line); policy lives with the rubric it verifies, never in code. - scanPatterns: String((r && r.scanPatterns) || '').slice(0, 4000), - })) - .filter(r => r.id && r.name) - .slice(0, 50) - } else { - sanitizedRubrics = Array.isArray(existing.complianceRubrics) ? existing.complianceRubrics : [] - } - - // overleaf-lab: clamp the context window to a sane range; keep existing (or the - // 32000 default) when not provided. - let sanitizedMaxContextTokens - if (maxContextTokens !== undefined) { - const parsed = parseInt(maxContextTokens, 10) - sanitizedMaxContextTokens = Number.isNaN(parsed) - ? existing.maxContextTokens || 32000 - : Math.min(1000000, Math.max(2000, parsed)) - } else { - sanitizedMaxContextTokens = existing.maxContextTokens || 32000 - } - - // overleaf-lab: clamp the review answer budget. This is the model's max_tokens for - // the report AND the room reserved for it in the context check, so it is bounded - // well below any real context window. - let sanitizedReviewMaxTokens - if (reviewMaxTokens !== undefined) { - const parsed = parseInt(reviewMaxTokens, 10) - sanitizedReviewMaxTokens = Number.isNaN(parsed) - ? existing.reviewMaxTokens || DEFAULT_REVIEW_MAX_TOKENS - : Math.min(128000, Math.max(500, parsed)) - } else { - sanitizedReviewMaxTokens = existing.reviewMaxTokens || DEFAULT_REVIEW_MAX_TOKENS - } - - // overleaf-lab: validate each rubric's scan patterns ("Label :: regex" per line) - // so the admin learns about a broken regex at save time, not from a silently - // hint-less review. The reviewer side skips invalid lines anyway (defense in - // depth for settings written by other means). - if (Array.isArray(complianceRubrics)) { - for (const r of complianceRubrics) { - const patternsText = r && typeof r.scanPatterns === 'string' ? r.scanPatterns : '' - if (patternsText.length > 4000) { - return res.status(400).json({ - error: `Scan patterns of rubric "${(r && r.name) || '?'}" must be 4000 characters or fewer`, - }) - } - for (const rawLine of patternsText.split('\n')) { - const line = rawLine.trim() - if (!line) { - continue - } - const sep = line.indexOf('::') - const body = (sep === -1 ? line : line.slice(sep + 2)).trim() - if (!body) { - continue - } - try { - // eslint-disable-next-line no-new - new RegExp(body, 'i') - } catch (err) { - return res.status(400).json({ - error: `Invalid scan pattern regex in rubric "${(r && r.name) || '?'}": ${body}`, - }) - } - } + // overleaf-lab: validate each rubric's scan patterns ("Label :: regex" per line) + // so the admin learns about a broken regex at save time, not from a silently + // hint-less review. The reviewer side skips invalid lines anyway (defense in + // depth for settings written by other means). + if (Array.isArray(complianceRubrics)) { + for (const r of complianceRubrics) { + const patternsText = r && typeof r.scanPatterns === 'string' ? r.scanPatterns : '' + if (patternsText.length > 4000) { + return res.status(400).json({ + error: `Scan patterns of rubric "${(r && r.name) || '?'}" must be 4000 characters or fewer`, + }) + } + for (const rawLine of patternsText.split('\n')) { + const line = rawLine.trim() + if (!line) { + continue } - } - - // overleaf-lab: sanitize the action prompt overrides. When provided, keep only - // known keys with string values, each capped at 4000 chars. When not provided, - // keep the existing object untouched. - let sanitizedActionPrompts - if (askAiActionPrompts !== undefined) { - sanitizedActionPrompts = {} - for (const key of Object.keys(DEFAULT_ASK_AI_ACTION_PROMPTS)) { - const val = askAiActionPrompts[key] - if (typeof val === 'string') { - sanitizedActionPrompts[key] = val.slice(0, 4000) - } + const sep = line.indexOf('::') + const body = (sep === -1 ? line : line.slice(sep + 2)).trim() + if (!body) { + continue } - } else { - sanitizedActionPrompts = - existing.askAiActionPrompts && - typeof existing.askAiActionPrompts === 'object' && - !Array.isArray(existing.askAiActionPrompts) - ? existing.askAiActionPrompts - : {} + try { + // eslint-disable-next-line no-new + new RegExp(body, 'i') + } catch (err) { + return res.status(400).json({ + error: `Invalid scan pattern regex in rubric "${(r && r.name) || '?'}": ${body}`, + }) + } + } } + } - const updatedSettings = { - ...existing, - systemPrompt, - llmApiUrl: typeof llmApiUrl === 'string' ? llmApiUrl : (existing.llmApiUrl || ''), - allowedModels: Array.isArray(allowedModels) ? allowedModels : existing.allowedModels || [], - completionModel: typeof completionModel === 'string' ? completionModel : (existing.completionModel || ''), - complianceRubrics: sanitizedRubrics, - reviewModel: typeof reviewModel === 'string' ? reviewModel : (existing.reviewModel || ''), - maxContextTokens: sanitizedMaxContextTokens, - reviewMaxTokens: sanitizedReviewMaxTokens, - // overleaf-lab: omitted flag keeps the existing value (default true). - chatEnabled: typeof chatEnabled === 'boolean' ? chatEnabled : (existing.chatEnabled !== false), - completionEnabled: typeof completionEnabled === 'boolean' ? completionEnabled : (existing.completionEnabled !== false), - reviewEnabled: typeof reviewEnabled === 'boolean' ? reviewEnabled : (existing.reviewEnabled !== false), - // overleaf-lab: editable prompt overrides. An empty string is stored as-is - // and later falls back to the default via `|| DEFAULT`. - askAiSystemPrompt: typeof askAiSystemPrompt === 'string' ? askAiSystemPrompt : (existing.askAiSystemPrompt || ''), - errorPrompt: typeof errorPrompt === 'string' ? errorPrompt : (existing.errorPrompt || ''), - reviewSystemPrompt: typeof reviewSystemPrompt === 'string' ? reviewSystemPrompt : (existing.reviewSystemPrompt || ''), - askAiActionPrompts: sanitizedActionPrompts, + // overleaf-lab: sanitize the action prompt overrides. When provided, keep only + // known keys with string values, each capped at 4000 chars. When not provided, + // keep the existing object untouched. + let sanitizedActionPrompts + if (askAiActionPrompts !== undefined) { + sanitizedActionPrompts = {} + for (const key of Object.keys(DEFAULT_ASK_AI_ACTION_PROMPTS)) { + const val = askAiActionPrompts[key] + if (typeof val === 'string') { + sanitizedActionPrompts[key] = val.slice(0, 4000) + } } + } else { + sanitizedActionPrompts = + existing.askAiActionPrompts && + typeof existing.askAiActionPrompts === 'object' && + !Array.isArray(existing.askAiActionPrompts) + ? existing.askAiActionPrompts + : {} + } - if (typeof llmApiKey === 'string' && llmApiKey.trim().length > 0) { - updatedSettings.llmApiKey = encryptSecret(llmApiKey.trim()) // overleaf-lab: encrypt admin key at rest - } + const updatedSettings = { + ...existing, + systemPrompt, + llmApiUrl: typeof llmApiUrl === 'string' ? llmApiUrl : (existing.llmApiUrl || ''), + llmApiType: typeof llmApiType === 'string' ? llmApiType : (existing.llmApiType || ''), + allowedModels: Array.isArray(allowedModels) ? allowedModels : existing.allowedModels || [], + completionModel: typeof completionModel === 'string' ? completionModel : (existing.completionModel || ''), + complianceRubrics: sanitizedRubrics, + reviewModel: typeof reviewModel === 'string' ? reviewModel : (existing.reviewModel || ''), + maxContextTokens: sanitizedMaxContextTokens, + reviewMaxTokens: sanitizedReviewMaxTokens, + // overleaf-lab: omitted flag keeps the existing value (default true). + chatEnabled: typeof chatEnabled === 'boolean' ? chatEnabled : (existing.chatEnabled !== false), + completionEnabled: typeof completionEnabled === 'boolean' ? completionEnabled : (existing.completionEnabled !== false), + reviewEnabled: typeof reviewEnabled === 'boolean' ? reviewEnabled : (existing.reviewEnabled !== false), + // overleaf-lab: editable prompt overrides. An empty string is stored as-is + // and later falls back to the default via `|| DEFAULT`. + askAiSystemPrompt: typeof askAiSystemPrompt === 'string' ? askAiSystemPrompt : (existing.askAiSystemPrompt || ''), + errorPrompt: typeof errorPrompt === 'string' ? errorPrompt : (existing.errorPrompt || ''), + reviewSystemPrompt: typeof reviewSystemPrompt === 'string' ? reviewSystemPrompt : (existing.reviewSystemPrompt || ''), + askAiActionPrompts: sanitizedActionPrompts, + } - await writeAdminSettings(updatedSettings) - logger.info({ - length: systemPrompt.length, - llmApiUrl: !!updatedSettings.llmApiUrl, - hasLlmApiKey: !!updatedSettings.llmApiKey, - allowedModels: updatedSettings.allowedModels?.length || 0, - }, '[LLM] Admin settings updated') + if (typeof llmApiKey === 'string' && llmApiKey.trim().length > 0) { + updatedSettings.llmApiKey = encryptSecret(llmApiKey.trim()) // overleaf-lab: encrypt admin key at rest + } - res.json({ success: true }) + await writeAdminSettings(updatedSettings) + logger.info({ + length: systemPrompt.length, + llmApiUrl: !!updatedSettings.llmApiUrl, + llmApiType: !!updatedSettings.llmApiType, + hasLlmApiKey: !!updatedSettings.llmApiKey, + allowedModels: updatedSettings.allowedModels?.length || 0, + }, '[LLM] Admin settings updated') + + res.json({ success: true }) } // Exported so LLMChatController can prepend the admin system prompt export async function getSystemPrompt() { - const settings = await readAdminSettings() - return settings.systemPrompt || null + const settings = readAdminSettings() + return settings.systemPrompt || null } export async function getAdminLLMSettings() { - const settings = await readAdminSettings() - // overleaf-lab: fall back to env so the model scan / connection-check and the - // chat share the same effective config (mirrors buildDisplaySettings above). - const jsonHasModels = - Array.isArray(settings.allowedModels) && settings.allowedModels.length > 0 - // overleaf-lab: the stored admin key is encrypted at rest; decrypt before use. - // decryptSecret returns legacy plaintext (no enc:v1: prefix) unchanged. - const jsonKey = settings.llmApiKey ? decryptSecret(settings.llmApiKey) : '' - return { - llmApiUrl: settings.llmApiUrl || process.env.LLM_API_URL || null, - llmApiKey: jsonKey || process.env.LLM_API_KEY || null, - allowedModels: jsonHasModels ? settings.allowedModels : envModelList(), - completionModel: settings.completionModel || '', - // overleaf-lab: document compliance review settings - reviewModel: settings.reviewModel || '', - maxContextTokens: settings.maxContextTokens || 32000, - reviewMaxTokens: settings.reviewMaxTokens || DEFAULT_REVIEW_MAX_TOKENS, - // overleaf-lab: per-feature enable flags (absent field defaults to true). - chatEnabled: settings.chatEnabled !== false, - completionEnabled: settings.completionEnabled !== false, - reviewEnabled: settings.reviewEnabled !== false, - } + const settings = readAdminSettings() + // overleaf-lab: fall back to env so the model scan / connection-check and the + // chat share the same effective config (mirrors buildDisplaySettings above). + const jsonHasModels = + Array.isArray(settings.allowedModels) && settings.allowedModels.length > 0 + // overleaf-lab: the stored admin key is encrypted at rest; decrypt before use. + // decryptSecret returns legacy plaintext (no enc:v1: prefix) unchanged. + const jsonKey = settings.llmApiKey ? decryptSecret(settings.llmApiKey) : '' + return { + llmApiUrl: settings.llmApiUrl || process.env.LLM_API_URL || null, + llmApiType: settings.llmApiType || process.env.LLM_API_TYPE || null, + llmApiKey: jsonKey || process.env.LLM_API_KEY || null, + allowedModels: jsonHasModels ? settings.allowedModels : envModelList(), + completionModel: settings.completionModel || '', + // overleaf-lab: document compliance review settings + reviewModel: settings.reviewModel || '', + maxContextTokens: settings.maxContextTokens || 32000, + reviewMaxTokens: settings.reviewMaxTokens || DEFAULT_REVIEW_MAX_TOKENS, + // overleaf-lab: per-feature enable flags (absent field defaults to true). + chatEnabled: settings.chatEnabled !== false, + completionEnabled: settings.completionEnabled !== false, + reviewEnabled: settings.reviewEnabled !== false, + } } // overleaf-lab: per-feature enable flags for the chat, inline completion, and @@ -374,149 +364,113 @@ export async function getAdminLLMSettings() { // installs keep every feature on. Used for backend enforcement across the // project-scoped controllers and the user settings page. export async function getLLMFeatureFlags() { - const s = await readAdminSettings() - return { - chatEnabled: s.chatEnabled !== false, - completionEnabled: s.completionEnabled !== false, - reviewEnabled: s.reviewEnabled !== false, - } + const s = readAdminSettings() + return { + chatEnabled: s.chatEnabled !== false, + completionEnabled: s.completionEnabled !== false, + reviewEnabled: s.reviewEnabled !== false, + } } // overleaf-lab: exposed so the compliance controller can load the configured // rubrics (readAdminSettings already handles the missing-file case). export async function getComplianceRubrics() { - const settings = await readAdminSettings() - return Array.isArray(settings.complianceRubrics) ? settings.complianceRubrics : [] + const settings = readAdminSettings() + return Array.isArray(settings.complianceRubrics) ? settings.complianceRubrics : [] } // overleaf-lab: resolve the EFFECTIVE editable prompts (admin override when set, // else the shipped default). Consumed by the compliance reviewer and the // project-scoped GET /llm/prompts endpoint so the frontend and backend agree. export async function getLLMPrompts() { - const s = await readAdminSettings() - return { - askAiSystemPrompt: s.askAiSystemPrompt || DEFAULT_ASK_AI_SYSTEM_PROMPT, - errorPrompt: s.errorPrompt || DEFAULT_ERROR_PROMPT, - reviewSystemPrompt: s.reviewSystemPrompt || DEFAULT_REVIEW_SYSTEM_PROMPT, - askAiActionPrompts: mergeActionPrompts(s.askAiActionPrompts), - } + const s = readAdminSettings() + return { + askAiSystemPrompt: s.askAiSystemPrompt || DEFAULT_ASK_AI_SYSTEM_PROMPT, + errorPrompt: s.errorPrompt || DEFAULT_ERROR_PROMPT, + reviewSystemPrompt: s.reviewSystemPrompt || DEFAULT_REVIEW_SYSTEM_PROMPT, + askAiActionPrompts: mergeActionPrompts(s.askAiActionPrompts), + } } async function checkAdminLLMConnection(req, res) { - const { apiUrl, apiKey } = req.body - const adminSettings = await getAdminLLMSettings() - const effectiveUrl = apiUrl || adminSettings.llmApiUrl - const effectiveKey = apiKey || adminSettings.llmApiKey - // overleaf-lab: use first configured/allowed model instead of hardcoded 'qwen3-32b' - const testModel = - adminSettings.allowedModels[0] || - (process.env.LLM_MODEL_NAME || 'default').split(',')[0].trim() + const { apiUrl, apiKey, apiType } = req.body + const adminSettings = await getAdminLLMSettings() + const llmApiUrl = apiUrl || adminSettings.llmApiUrl + const llmApiType = apiType || adminSettings.llmApiType + const llmApiKey = apiKey || adminSettings.llmApiKey - // overleaf-lab: only the URL is required. A local llama.cpp server has no - // auth, so an empty key is valid; send Authorization only when a key exists. - if (!effectiveUrl) { - return res.status(400).json({ - success: false, - error: 'LLM API URL is required', - }) - } + // use first configured/allowed model, if no model is configured, use a model hardcoded in the provider code + const testModel = + adminSettings.allowedModels[0] || + (process.env.LLM_MODEL_NAME || '').split(',')[0].trim() - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), 30000) + if (!llmApiUrl || !llmApiType) { + return res.status(400).json({ + success: false, + error: 'LLM API URL and type is required', + }) + } - try { - const headers = { 'Content-Type': 'application/json' } - if (typeof effectiveKey === 'string' && effectiveKey.length > 0) { - headers.Authorization = `Bearer ${effectiveKey}` - } - const response = await fetch(`${effectiveUrl}/chat/completions`, { - method: 'POST', - headers, - body: JSON.stringify({ - model: testModel, - messages: [{ role: 'user', content: 'Test connection' }], - max_tokens: 1, - }), - signal: controller.signal, - }) + try { + const provider = createLLMProvider({ llmApiUrl, llmApiKey, llmApiType }) + await provider.checkConnection(testModel) - clearTimeout(timeout) + res.json({ success: true, message: 'Connection successful' }) - if (!response.ok) { - const body = await response.text() - return res.status(400).json({ - success: false, - error: 'LLM connection failed', - status: response.status, - details: body, - }) - } - - res.json({ success: true, message: 'Connection successful' }) - } catch (err) { - clearTimeout(timeout) - if (err.name === 'AbortError') { - return res.status(504).json({ - success: false, - error: 'Connection timeout', - }) - } - logger.error({ err }, '[LLM] Admin connection check failed') - res.status(500).json({ success: false, error: 'Connection attempt failed' }) - } + } catch (err) { + const info = OError.getFullInfo(err) + const errStatus = info?.status || 500 + logger.error({ err }, '[LLM] Admin connection check failed') + res.status(errStatus).json({ + success: false, + error: 'LLM connection failed', + status: errStatus, + details: err.message, + }) + } } async function scanAdminModels(req, res) { - const { apiUrl, apiKey } = req.query - const adminSettings = await getAdminLLMSettings() - const llmApiUrl = apiUrl || adminSettings.llmApiUrl - const llmApiKey = apiKey || adminSettings.llmApiKey + const { apiUrl, apiKey, apiType } = req.query + const adminSettings = await getAdminLLMSettings() + const llmApiUrl = apiUrl || adminSettings.llmApiUrl + const llmApiType = apiType || adminSettings.llmApiType + const llmApiKey = apiKey || adminSettings.llmApiKey - // overleaf-lab: only the URL is required. A local llama.cpp server has no - // auth, so an empty key is valid; send Authorization only when a key exists. - if (!llmApiUrl) { - return res.status(400).json({ - success: false, - error: 'Admin LLM API URL must be configured first', - }) - } + if (!llmApiUrl || !llmApiType) { + return res.status(400).json({ + success: false, + error: 'Admin LLM API URL and type must be configured first', + }) + } - try { - const headers = {} - if (typeof llmApiKey === 'string' && llmApiKey.length > 0) { - headers.Authorization = `Bearer ${llmApiKey}` - } - const response = await fetch(`${llmApiUrl}/models`, { - method: 'GET', - headers, - }) + try { + const provider = createLLMProvider({ llmApiUrl, llmApiKey, llmApiType }) + const result = await provider.listModels() - if (!response.ok) { - const body = await response.text() - return res.status(400).json({ - success: false, - error: 'Failed to fetch models', - status: response.status, - details: body, - }) - } + const ids = Array.isArray(result?.data) + ? result.data.map(entry => String(entry.id)) + : [] - const data = await response.json() - const ids = Array.isArray(data?.data) - ? data.data.map(entry => String(entry.id)) - : [] + res.json({ success: true, models: ids }) - res.json({ success: true, models: ids }) - } catch (error) { - logger.error({ err: error }, '[LLM] Admin model scan failed') - res.status(500).json({ success: false, error: 'Model scan failed' }) - } + } catch (error) { + const info = OError.getFullInfo(err) + const errStatus = info?.status || 500 + logger.error({ err }, '[LLM] Admin connection check failed') + res.status(errStatus).json({ + success: false, + error: 'Model scan failed', + status: errStatus, + details: err.message, + }) + } } export default { - adminSettingsPage: expressify(adminSettingsPage), - getAdminSettings: expressify(getAdminSettings), - saveAdminSettings: expressify(saveAdminSettings), - checkAdminLLMConnection: expressify(checkAdminLLMConnection), - scanAdminModels: expressify(scanAdminModels), + adminSettingsPage: expressify(adminSettingsPage), + getAdminSettings: expressify(getAdminSettings), + saveAdminSettings: expressify(saveAdminSettings), + checkAdminLLMConnection: expressify(checkAdminLLMConnection), + scanAdminModels: expressify(scanAdminModels), } diff --git a/services/web/modules/llm/app/src/LLMChatController.mjs b/services/web/modules/llm/app/src/LLMChatController.mjs index 105bc8fa62..03c416cbff 100644 --- a/services/web/modules/llm/app/src/LLMChatController.mjs +++ b/services/web/modules/llm/app/src/LLMChatController.mjs @@ -2,12 +2,14 @@ import logger from '@overleaf/logger' import fetch from 'node-fetch' import { AbortController } from 'abort-controller' import { expressify } from '@overleaf/promise-utils' +import OError from '@overleaf/o-error' import SessionManager from '../../../../app/src/Features/Authentication/SessionManager.mjs' import { User } from '../../../../app/src/models/User.mjs' import ProjectEntityHandler from '../../../../app/src/Features/Project/ProjectEntityHandler.mjs' // overleaf-lab: read project docs for error source context import Settings from '@overleaf/settings' import { getSystemPrompt, getAdminLLMSettings, getLLMFeatureFlags, getLLMPrompts } from './LLMAdminController.mjs' import { decryptSecret } from './LLMCrypto.mjs' // overleaf-lab: decrypt user API keys stored at rest +import { createLLMProvider } from './LLMProviderFactory.mjs' // Helper function to remove tags (for DeepSeek, Qwen and similar models) // Handles both closed ... and unclosed ... at end of string @@ -229,23 +231,19 @@ async function chat(req, res) { }) } + // if model is not configured, the model hardcoded in the provider code will be used const modelNameForApi = personalModelName ? personalModelName - : model || ((process.env.LLM_MODEL_NAME || process.env.LLM_AVAILABLE_MODELS || 'default').split(',')[0].trim()) // overleaf-lab: env-configurable fallback instead of hardcoded 'qwen3-32b' + : model || ((process.env.LLM_MODEL_NAME || process.env.LLM_AVAILABLE_MODELS || '').split(',')[0].trim()) - const controller = new AbortController() - const timeout = setTimeout(() => { - controller.abort() - }, 300000) // 5 minutes + const provider = createLLMProvider({ llmApiUrl, llmApiKey }) try { - const llmApiFullUrl = `${llmApiUrl}/chat/completions` - // Prepend the admin-configured system prompt (if any) plus an always-on // instruction to reply in the user's language, then merge any client // system message. This keeps replies in the input language regardless of // whether an admin prompt is set. - const languageInstruction = `Reply in the same language as the user's latest message (for example, answer in Italian if the user writes in Italian).` + const languageInstruction = "Reply in the same language as the user's latest message (for example, answer in Italian if the user writes in Italian)." const adminSystemPrompt = await getSystemPrompt() const systemPreamble = adminSystemPrompt ? `${adminSystemPrompt}\n\n${languageInstruction}` @@ -271,99 +269,37 @@ async function chat(req, res) { { projectId, model: modelNameForApi, - url: llmApiFullUrl, + url: llmApiUrl, isPersonalModel, maxTokens: 8192, }, '[LLM] chat: sending request to LLM API' ) - // overleaf-lab: send Authorization only when a non-empty key exists, so a - // keyless local server is not sent a malformed empty Bearer header. - const chatHeaders = { 'Content-Type': 'application/json' } - if (typeof llmApiKey === 'string' && llmApiKey.length > 0) { - chatHeaders.Authorization = `Bearer ${llmApiKey}` - } - const response = await fetch(llmApiFullUrl, { - method: 'POST', - headers: chatHeaders, - body: JSON.stringify(requestBody), - signal: controller.signal, - }) - - clearTimeout(timeout) const duration = Date.now() - startTime - logger.info( - { projectId, status: response.status, duration: `${duration}ms` }, - '[LLM] chat: LLM API responded' - ) - - if (!response.ok) { - const errorText = await response.text() - logger.error( - { - projectId, - userId, - status: response.status, - error: errorText, - duration: `${duration}ms`, - }, - '[LLM] API error response' - ) - return res.status(response.status).json({ - error: 'LLM API error', - details: errorText, - status: response.status, - }) - } - - const data = await response.json() - - // Strip tags from the response content - if ( - data.choices && - data.choices[0] && - data.choices[0].message && - data.choices[0].message.content - ) { - const originalLength = data.choices[0].message.content.length - data.choices[0].message.content = stripThinkTags( - data.choices[0].message.content - ) - const strippedLength = data.choices[0].message.content.length - if (originalLength !== strippedLength) { - logger.debug( - { originalLength, strippedLength }, - '[LLM] chat: stripped tags' - ) - } - } + const data = await provider.chat({ + model: modelNameForApi, + messages: finalMessages, + max_tokens: 8192, +// temperature: 0.7, + }) logger.info( { projectId, duration: `${Date.now() - startTime}ms`, model: modelNameForApi }, '[LLM] chat: response sent successfully' ) res.json(data) - } catch (error) { - clearTimeout(timeout) - if (error.name === 'AbortError') { - return res.status(504).json({ - error: 'LLM service timeout', - details: 'The LLM API did not respond within 5 minutes', - }) - } - - logger.error( - { projectId, userId, err: error }, - '[LLM] Error communicating with LLM service' - ) - - res.status(500).json({ - error: 'Failed to communicate with LLM service', - details: error.message, + } catch (err) { + const info = OError.getFullInfo(err) + const errStatus = info?.status || 500 + logger.error({ projectId, userId, err }, '[LLM] Error communicating with LLM service') + return res.status(errStatus).json({ + error: err.message, + details: info?.error?.message || error.cause?.message, + status: errStatus, }) } } @@ -485,71 +421,31 @@ async function completion(req, res) { return res.status(503).json({ success: false, error: 'LLM service is not configured' }) } - const controller = new AbortController() - const timeout = setTimeout(() => { - controller.abort() - }, 30000) // 30 seconds for completions - + const provider = createLLMProvider({ llmApiUrl, llmApiKey }) try { const systemPrompt = `/no_think\nYou are a text completion engine. Output ONLY the missing text, in the same language as the surrounding text. No thinking, no explanation, no markdown, no code fences, no tags. Just the raw continuation characters.` + const userPrompt = `Complete the text at [CURSOR]. Output only the few words that replace [CURSOR]:\n\n${leftContext}[CURSOR]${rightContext}` - const userPrompt = `Complete the text at [CURSOR]. Output only the few words that replace [CURSOR]: +console.log("userPrompt = ", userPrompt) -${leftContext}[CURSOR]${rightContext}` - - // overleaf-lab: send Authorization only when a non-empty key exists. - const completionHeaders = { 'Content-Type': 'application/json' } - if (typeof llmApiKey === 'string' && llmApiKey.length > 0) { - completionHeaders.Authorization = `Bearer ${llmApiKey}` - } - - const response = await fetch(`${llmApiUrl}/chat/completions`, { - method: 'POST', - headers: completionHeaders, - body: JSON.stringify({ - model: completionModel, - messages: [ - { role: 'system', content: systemPrompt }, - { role: 'user', content: userPrompt }, - ], - max_tokens: maxLength || 60, - temperature: 0.1, - }), - signal: controller.signal, + const data = await provider.completion({ + model: completionModel, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + max_tokens: maxLength || 60, }) - clearTimeout(timeout) - - if (!response.ok) { - return res - .status(response.status) - .json({ success: false, error: 'Completion request failed' }) - } - - const data = await response.json() - const completionText = - data.choices?.[0]?.message?.content?.trim() || '' - - // Strip any think tags and clean up - const cleaned = stripThinkTags(completionText) - - res.json({ success: true, data: cleaned }) - } catch (error) { - clearTimeout(timeout) - - if (error.name === 'AbortError') { - return res - .status(504) - .json({ success: false, error: 'Completion timeout' }) - } - - logger.error( - { projectId, userId, err: error }, - '[LLM] Completion error' - ) - res - .status(500) - .json({ success: false, error: 'Completion failed' }) + res.json({ success: true, data }) + } catch (err) { + const info = OError.getFullInfo(err) + const errStatus = info?.status || 500 + logger.error({ projectId, userId, err }, '[LLM] Completion error') + return res.status(errStatus).json({ + success: false, + error: 'Completion failed', + }) } } diff --git a/services/web/modules/llm/app/src/LLMComplianceController.mjs b/services/web/modules/llm/app/src/LLMComplianceController.mjs index d1665cc63d..370eede464 100644 --- a/services/web/modules/llm/app/src/LLMComplianceController.mjs +++ b/services/web/modules/llm/app/src/LLMComplianceController.mjs @@ -605,6 +605,7 @@ function extractJson(text) { .replace(/^```json\s*/i, '') .replace(/^```\s*/i, '') .replace(/```\s*$/i, '') +// Fricking vim makes the rest of the file red without this ` const first = cleaned.indexOf('{') const last = cleaned.lastIndexOf('}') if (first === -1 || last === -1 || last < first) { diff --git a/services/web/modules/llm/app/src/LLMErrors.mjs b/services/web/modules/llm/app/src/LLMErrors.mjs new file mode 100644 index 0000000000..804541f201 --- /dev/null +++ b/services/web/modules/llm/app/src/LLMErrors.mjs @@ -0,0 +1,6 @@ +import OError from '@overleaf/o-error' +class ProviderRequestError extends OError {} + +export { + ProviderRequestError +} diff --git a/services/web/modules/llm/app/src/LLMProviderFactory.mjs b/services/web/modules/llm/app/src/LLMProviderFactory.mjs new file mode 100644 index 0000000000..25a7cded17 --- /dev/null +++ b/services/web/modules/llm/app/src/LLMProviderFactory.mjs @@ -0,0 +1,13 @@ +import OpenAIProvider from './providers/OpenAIProvider.mjs' +import AnthropicProvider from './providers/AnthropicProvider.mjs' + +export function createLLMProvider(settings) { + switch (settings.llmProvider) { + case 'openai': + return new OpenAIProvider(settings) + + case 'anthropic': + default: + return new AnthropicProvider(settings) + } +} diff --git a/services/web/modules/llm/app/src/providers/AnthropicProvider.mjs b/services/web/modules/llm/app/src/providers/AnthropicProvider.mjs new file mode 100644 index 0000000000..e82fb9ac47 --- /dev/null +++ b/services/web/modules/llm/app/src/providers/AnthropicProvider.mjs @@ -0,0 +1,103 @@ +import BaseProvider from './BaseProvider.mjs' + +// Helper function to remove tags (for DeepSeek, Qwen and similar models) +// Handles both closed ... and unclosed ... at end of string +function stripThinkTags(content) { + let cleaned = content.replace(/[\s\S]*?<\/think>\s*/gi, '') + cleaned = cleaned.replace(/[\s\S]*/gi, '') + return cleaned.trim() +} + +export default class AnthropicProvider extends BaseProvider { + headers() { + const headers = { + 'Content-Type': 'application/json', + 'anthropic-version': '2023-06-01', + } + + if (this.apiKey) { + headers['x-api-key'] = this.apiKey + } + + return headers + } + + async listModels() { + return this.fetch('/v1/models') + } + + async checkConnection(model) { + return this.chat({ + model: model || 'claude-sonnet-5', + messages: [ + { + role: 'user', + content: 'Test connection', + }, + ], + max_tokens: 1, + }) + } + + async chat(body) { + const data = await this.fetch('/v1/messages', { + method: 'POST', + body: JSON.stringify(this.buildRequest(body)), + }) + + return { + id: data.id, + model: data.model, + usage: data.usage, + choices: [ + { + index: 0, + finish_reason: data.stop_reason, + message: { + role: 'assistant', + content: this.extractText(data), + }, + }, + ], + } + } + + async complete(body) { + const data = await this.fetch('/v1/messages', { + method: 'POST', + body: JSON.stringify(this.buildRequest(body)), + }) + + return this.extractText(data) + } + + buildRequest(body) { + const { messages = [], ...rest } = body + + const system = messages + .filter(m => m.role === 'system') + .map(m => m.content) + .join('\n\n') + + const request = { + ...rest, + messages: messages.filter(m => m.role !== 'system'), + } + + if (system) { + request.system = system + } + + return request + } + + extractText(data) { + return stripThinkTags( + (data.content || []) + .filter(x => x.type === 'text') + .map(x => x.text) + .join('') + .trim() + ) + } +} diff --git a/services/web/modules/llm/app/src/providers/BaseProvider.mjs b/services/web/modules/llm/app/src/providers/BaseProvider.mjs new file mode 100644 index 0000000000..5475e8b37c --- /dev/null +++ b/services/web/modules/llm/app/src/providers/BaseProvider.mjs @@ -0,0 +1,67 @@ +import AbortError from 'node-fetch' +import { fetchJson, RequestFailedError } from '@overleaf/fetch-utils' +import OError from '@overleaf/o-error' + +const REQUEST_TIMEOUT_MS = 5 * 60 * 1000 + +function handleError(err) { + if (err.name === 'AbortError') { + throw new OError('LLM request timed out', { timeout: REQUEST_TIMEOUT_MS / 1000, status: 504 }).withCause(err) + } + const status = err.response?.status || 500 + + if (!(err instanceof RequestFailedError)) { + throw new OError('Something wrong with LLM request', { status }).withCause(err) + } + + let errInfo + try { + errInfo = JSON.parse(err.body) + } catch { + errInfo = { message: err.body } + } + + throw new OError('LLM request failed', errInfo).withCause(err) +} + +export default class BaseProvider { + constructor({ llmApiUrl, llmApiKey }) { + this.apiUrl = llmApiUrl.replace(/\/+$/, '') + this.apiKey = llmApiKey || '' + } + + async listModels() { + throw new Error('Not implemented') + } + + async checkConnection(model) { + throw new Error('Not implemented') + } + + async chat(body) { + throw new Error('Not implemented') + } + + async complete(body) { + throw new Error('Not implemented') + } + + async fetch(path, options = {}) { + return await fetchJson(`${this.apiUrl}${path}`, { + ...options, + headers: { + ...this.headers(), + ...(options.headers || {}), + }, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }).catch(err => { handleError(err) }) + } + + normalizeChatResponse(data) { + return data + } + + headers() { + return {} + } +} diff --git a/services/web/modules/llm/app/src/providers/OpenAIProvider.mjs b/services/web/modules/llm/app/src/providers/OpenAIProvider.mjs new file mode 100644 index 0000000000..551b74557d --- /dev/null +++ b/services/web/modules/llm/app/src/providers/OpenAIProvider.mjs @@ -0,0 +1,70 @@ +import BaseProvider from './BaseProvider.mjs' + +// Helper function to remove tags (for DeepSeek, Qwen and similar models) +// Handles both closed ... and unclosed ... at end of string +function stripThinkTags(content) { + let cleaned = content.replace(/[\s\S]*?<\/think>\s*/gi, '') + cleaned = cleaned.replace(/[\s\S]*/gi, '') + return cleaned.trim() +} + +export default class OpenAIProvider extends BaseProvider { + headers() { + const headers = { + 'Content-Type': 'application/json', + } + + if (this.apiKey) { + headers.Authorization = `Bearer ${this.apiKey}` + } + + return headers + } + + async listModels() { + return this.fetch('/models') + } + + async checkConnection(model) { + return this.chat({ + model: model || 'default', + messages: [ + { + role: 'user', + content: 'Test connection', + }, + ], + max_tokens: 1, + }) + } + + async chat(body) { + const data = await this.fetch('/chat/completions', { + method: 'POST', + body: JSON.stringify(body), + }) + + if (!data?.choices?.[0]?.message) { + throw new Error('Invalid OpenAI compatible provider chat response') + } + + data.choices[0].message.content = this.extractText(data) + + return data + } + + async complete(body) { + const data = this.fetch('/chat/completions', { + method: 'POST', + body: JSON.stringify(body), + }) + + return this.extractText(data) + } + + extractText(data) { + return stripThinkTags( + data?.choices?.[0]?.message?.content?.trim() || '' + ) + } +} diff --git a/services/web/modules/llm/app/views/llm-admin-settings.pug b/services/web/modules/llm/app/views/llm-admin-settings.pug index 348c288b0c..2c4de8905a 100644 --- a/services/web/modules/llm/app/views/llm-admin-settings.pug +++ b/services/web/modules/llm/app/views/llm-admin-settings.pug @@ -9,10 +9,12 @@ block vars block append meta meta(name='ol-systemPrompt' data-type='string' content=systemPrompt) meta(name='ol-llmApiUrl' data-type='string' content=llmApiUrl) + meta(name='ol-llmApiType' data-type='string' content=llmApiType) meta(name='ol-hasLlmApiKey' data-type='string' content=hasLlmApiKey ? 'true' : 'false') meta(name='ol-allowedModels' data-type='string' content=(allowedModels || []).join(',')) meta(name='ol-completionModel' data-type='string' content=completionModel) meta(name='ol-llmApiUrlFromEnv' data-type='string' content=llmApiUrlFromEnv ? 'true' : 'false') + meta(name='ol-llmApiTypeFromEnv' data-type='string' content=llmApiTypeFromEnv ? 'true' : 'false') meta(name='ol-complianceRubrics' data-type='json' content=JSON.stringify(complianceRubrics || [])) meta(name='ol-reviewModel' data-type='string' content=reviewModel) meta(name='ol-maxContextTokens' data-type='string' content=String(maxContextTokens)) diff --git a/services/web/modules/llm/frontend/js/components/llm-admin-settings-page.tsx b/services/web/modules/llm/frontend/js/components/llm-admin-settings-page.tsx index 2bcd0b1e96..a52536131f 100644 --- a/services/web/modules/llm/frontend/js/components/llm-admin-settings-page.tsx +++ b/services/web/modules/llm/frontend/js/components/llm-admin-settings-page.tsx @@ -13,6 +13,18 @@ import OLRow from '@/shared/components/ol/ol-row' import OLCol from '@/shared/components/ol/ol-col' import OLBadge from '@/shared/components/ol/ol-badge' import MaterialIcon from '@/shared/components/material-icon' +import { + Dropdown, + DropdownToggle, + DropdownMenu, + DropdownItem, +} from '@/shared/components/dropdown/dropdown-menu' +import '../../stylesheets/llm-settings.scss' + +const LLM_API_TYPES = [ + { name: 'openai', label: 'OpenAI' }, + { name: 'anthropic', label: 'Anthropic' }, +] const DEFAULT_SYSTEM_PROMPT = `You are an expert LaTeX debugging assistant and compiler error specialist. @@ -59,29 +71,6 @@ const DEFAULT_SYSTEM_PROMPT = `You are an expert LaTeX debugging assistant and c Remember: The user is likely frustrated. Be encouraging and clear!` -const sectionStyle: React.CSSProperties = { - padding: '1.5rem', - borderRadius: '8px', - border: '1px solid var(--border-color-01, #dee2e6)', - backgroundColor: 'var(--bg-light-primary, #fff)', - marginBottom: '1.25rem', -} - -const sectionHeaderStyle: React.CSSProperties = { - display: 'flex', - alignItems: 'center', - gap: '0.5rem', - marginBottom: '0.25rem', - fontSize: '1.1rem', - fontWeight: 600, -} - -const sectionDescStyle: React.CSSProperties = { - color: 'var(--content-secondary, #6c757d)', - fontSize: '0.875rem', - marginBottom: '1.25rem', -} - const statusBadgeStyle = (variant: 'success' | 'error'): React.CSSProperties => ({ display: 'inline-flex', alignItems: 'center', @@ -93,20 +82,6 @@ const statusBadgeStyle = (variant: 'success' | 'error'): React.CSSProperties => : 'var(--red-60, #dc3545)', }) -const stepNumberStyle: React.CSSProperties = { - display: 'inline-flex', - alignItems: 'center', - justifyContent: 'center', - width: '1.5rem', - height: '1.5rem', - borderRadius: '50%', - backgroundColor: 'var(--bg-accent-01, #0d6efd)', - color: '#fff', - fontSize: '0.75rem', - fontWeight: 700, - flexShrink: 0, -} - // overleaf-lab: a small accessible toggle switch (styled from a button) used for // the per-feature enable/disable controls. function ToggleSwitch({ @@ -119,8 +94,7 @@ function ToggleSwitch({ label?: string }) { return ( - + ) } @@ -163,6 +137,7 @@ export default function LLMAdminSettingsPage() { // overleaf-lab: true when the shown URL is inherited from the LLM_API_URL env // var rather than saved in the admin settings file. const apiUrlFromEnv = getMeta('ol-llmApiUrlFromEnv') === 'true' + const apiTypeFromEnv = getMeta('ol-llmApiTypeFromEnv') === 'true' const [systemPrompt, setSystemPrompt] = useState( (getMeta('ol-systemPrompt') as string) || DEFAULT_SYSTEM_PROMPT @@ -170,6 +145,9 @@ export default function LLMAdminSettingsPage() { const [llmApiUrl, setLlmApiUrl] = useState( (getMeta('ol-llmApiUrl') as string) || '' ) + const [llmApiType, setLlmApiType] = useState( + (getMeta('ol-llmApiType') as string) || '' + ) const [llmApiKey, setLlmApiKey] = useState('') const [allowedModels, setAllowedModels] = useState( ((getMeta('ol-allowedModels') as string) || '') @@ -228,10 +206,11 @@ export default function LLMAdminSettingsPage() { } }, [isSuccess]) - // overleaf-lab: only the URL is required — a local llama.cpp server has no + // overleaf-lab: only the URL and its type are required — a local llama.cpp server has no // auth, so scan/test must work with an empty key. The server returns 401 if // it actually needs one. - const canConnect = !!llmApiUrl + const canConnect = !!llmApiUrl && !!llmApiType + const selectedApiType = LLM_API_TYPES.find(t => t.name === llmApiType) const handleSave = (e: React.FormEvent) => { e.preventDefault() @@ -240,6 +219,7 @@ export default function LLMAdminSettingsPage() { body: { systemPrompt, llmApiUrl, + llmApiType, llmApiKey, allowedModels, completionModel, @@ -327,718 +307,800 @@ export default function LLMAdminSettingsPage() { } const allModels = Array.from(new Set([...availableModels, ...allowedModels])) +// const allModels = availableModels return ( -
- - -
- {/* Page header */} -
-

- - {t('llm_configuration', 'LLM Configuration')} -

-

- {t( - 'llm_admin_description', - 'Configure the AI assistant for your Overleaf instance. Set up the API connection, choose available models, and customize the system prompt.' - )} -

-
+
+ {/* Page header */} +
+

+ + {t('llm_configuration', 'LLM Configuration')} +

+

+ {t( + 'llm_admin_description', + 'Configure the AI assistant for your Overleaf instance. Set up the API connection, choose available models, and customize the system prompt.' + )} +

+
-
- {/* ── Section 1: Features ── */} - {/* overleaf-lab: master on/off switches per AI feature */} -
-
- 1 - - {t('llm_features', 'Features')} -
-

- {t( - 'llm_features_desc', - 'Enable or disable each AI feature for all users. A disabled feature cannot be used by anyone, even with their own API key.' - )} -

+ + {/* ── Section 1: Features ── */} + {/* overleaf-lab: master on/off switches per AI feature */} +
+
+ 1 + + {t('llm_features', 'Features')} +
+

+ {t( + 'llm_features_desc', + 'Enable or disable each AI feature for all users. A disabled feature cannot be used by anyone, even with their own API key.' + )} +

-
- {/* overleaf-lab: one toggle switch per feature */} - {[ - { key: 'chat', on: chatEnabled, set: setChatEnabled, title: t('feature_chat', 'Chat'), help: t('feature_chat_help', 'The AI chat panel and Ask AI on selection.') }, - { key: 'completion', on: completionEnabled, set: setCompletionEnabled, title: t('feature_completion', 'Inline completion'), help: t('feature_completion_help', 'Autocomplete suggestions while typing.') }, - { key: 'review', on: reviewEnabled, set: setReviewEnabled, title: t('feature_review', 'Compliance review'), help: t('feature_review_help', 'The whole-document review.') }, - ].map((f, i, arr) => ( -
-
- {f.title} - {f.help} -
- -
- ))} -
-
+
+ {/* overleaf-lab: one toggle switch per feature */} + {[ + { key: 'chat', on: chatEnabled, set: setChatEnabled, title: t('feature_chat', 'Chat'), help: t('feature_chat_help', 'The AI chat panel and Ask AI on selection.') }, + { key: 'completion', on: completionEnabled, set: setCompletionEnabled, title: t('feature_completion', 'Inline completion'), help: t('feature_completion_help', 'Autocomplete suggestions while typing.') }, + { key: 'review', on: reviewEnabled, set: setReviewEnabled, title: t('feature_review', 'Compliance review'), help: t('feature_review_help', 'The whole-document review.') }, + ].map((f, i, arr) => ( +
+
+ {f.title} + {f.help} +
+ +
+ ))} +
+
- {/* ── Section 2: API Connection ── */} -
-
- 2 - - {t('api_connection', 'API Connection')} - {testStatus === 'success' && ( - - {t('connected', 'Connected')} - - )} -
-

- {t( - 'api_connection_desc', - 'Enter the endpoint URL and API key for your OpenAI-compatible LLM provider.' - )} -

+ {/* ── Section 2: API Connection ── */} +
+
+ 2 + + {t('api_connection', 'API Connection')} + {testStatus === 'success' && ( + + {t('connected', 'Connected')} + + )} +
+

+ {t( + 'api_connection_desc', + 'Enter the endpoint URL and API key for your OpenAI-compatible LLM provider.' + )} +

- - - {t('llm_api_url', 'API Endpoint URL')} - - ) => - setLlmApiUrl(e.target.value) - } - placeholder="https://api.example.com/v1" - /> - {apiUrlFromEnv && ( - - - {t('llm_admin_from_env', 'Inherited from the LLM_API_URL environment variable. Saving here stores it in the admin settings file.')} - - )} - + + + {t('llm_api_url', 'API Endpoint URL')} + + ) => + setLlmApiUrl(e.target.value) + } + placeholder="https://api.example.com/v1" + /> + {apiUrlFromEnv && ( + + + {t('llm_admin_from_env', 'Inherited from the LLM_API_URL environment variable. Saving here stores it in the admin settings file.')} + + )} + - - - {t('llm_api_key', 'API Key')} - - ) => - setLlmApiKey(e.target.value) - } - placeholder={ - hasStoredKey - ? t('llm_api_key_placeholder_stored', '•••••••• (stored — leave blank to keep)') - : t('llm_api_key_placeholder', 'Paste your API key here') - } - /> - {hasStoredKey && !llmApiKey && ( - - - {t('llm_api_key_stored', 'An API key is already stored. Leave blank to keep it.')} - - )} - - - {t('llm_api_key_optional_local', 'Leave blank for a local server with no auth (e.g. a llama.cpp server).')} - - + + + {t('llm_api_type', 'API Type')} + +
+ + + + {selectedApiType?.label ?? 'Select API type'} + + -
- - - {t('test_connection', 'Test Connection')} - - {testStatus === 'success' && ( - - - {t('connection_successful', 'Connection successful')} - - )} - {testStatus === 'error' && ( - - - {t('connection_failed', 'Connection failed — check URL and key')} - - )} -
-
+ + {LLM_API_TYPES.map(apiType => ( + setLlmApiType(apiType.name)} + > + {apiType.label} + + ))} + + +
- {/* ── Section 3: Model Selection ── */} -
-
- 3 - - {t('model_selection', 'Model Selection')} - {allModels.length > 0 && ( - - {allowedModels.filter(m => allModels.includes(m)).length}/{allModels.length} {t('selected', 'selected')} - - )} -
-

- {t( - 'model_selection_desc', - 'Scan the API for available models, then choose which ones users can access.' - )} -

- -
0 ? '1rem' : 0 }}> - - - {t('scan_for_models', 'Scan for Models')} - - {scanStatus === 'success' && ( - - - {t('scan_found_models', `Found ${availableModels.length} model(s)`)} - - )} - {scanStatus === 'error' && ( - - - {t('scan_failed', 'Scan failed — check connection first')} - - )} - {!canConnect && scanStatus === null && ( - - {t('configure_api_first', 'Configure the API connection above first')} - - )} -
- - {allModels.length > 0 && ( - <> -
- {allModels.map((model, idx) => ( - - ))} -
-
- setAllowedModels([...allModels])} - style={{ padding: 0, fontSize: '0.8125rem' }} - > - {t('select_all', 'Select all')} - - | - setAllowedModels([])} - style={{ padding: 0, fontSize: '0.8125rem' }} - > - {t('deselect_all', 'Deselect all')} - -
- - )} - - {/* overleaf-lab: admin picks the single shared inline-completion model */} - 0 ? '1rem' : 0 }}> - - {t('inline_completion_model', 'Inline completion model')} - - - - {t( - 'inline_completion_model_admin_help', - 'Model used for inline autocomplete on the shared backend. Can differ from the chat models. Set to Disabled to turn off shared autocomplete (users with their own API key still get it).' - )} - - -
- - {/* ── Section 4: System Prompt ── */} -
-
- 4 - - {t('system_prompt', 'System Prompt')} -
-

- {t( - 'system_prompt_desc', - 'This prompt is prepended to every AI conversation. Use it to customize the assistant\'s behavior for your organization.' - )} -

- - - ) => - setSystemPrompt(e.target.value) - } - placeholder={t( - 'llm_system_prompt_placeholder', - 'You are a helpful LaTeX assistant...' - )} - maxLength={4000} - style={{ fontFamily: 'monospace', fontSize: '0.8125rem' }} - /> - -
- - {systemPrompt.length}/4000 {t('characters', 'characters')} - - setSystemPrompt(DEFAULT_SYSTEM_PROMPT)} - style={{ padding: 0, fontSize: '0.8125rem' }} - > - - {t('reset_to_default', 'Reset to default')} - -
-
- - {/* ── Section 5: Compliance Review ── */} -
-
- 5 - - {t('compliance_review', 'Compliance Review')} -
-

- {t( - 'compliance_review_desc', - 'Configure the document compliance review: the guideline rubrics users can check against, the model that runs the review, and the maximum context size.' - )} -

- - {/* overleaf-lab: (a) rubrics editor */} - {complianceRubrics.length === 0 && ( -

- {t('no_rubrics_yet', 'No rubrics yet. Add one to enable the compliance review for users.')} -

- )} - {complianceRubrics.map(rubric => ( -
- - - {t('rubric_name', 'Rubric name')} - - ) => - updateRubric(rubric.id, 'name', e.target.value) - } - placeholder={t('rubric_name_placeholder', 'e.g. Thesis writing guidelines')} - /> - - - - {t('rubric_guidelines', 'Guidelines')} - - ) => - updateRubric(rubric.id, 'guidelines', e.target.value) - } - style={{ fontFamily: 'monospace', fontSize: '0.8125rem' }} - /> - - {/* overleaf-lab: per-rubric mechanical scans; policy - patterns live next to the guidelines they verify */} - - - {t('rubric_scan_patterns', 'Scan patterns (optional, one per line)')} - - ) => - updateRubric(rubric.id, 'scanPatterns', e.target.value) - } - placeholder={'First person :: (? - - {t( - 'rubric_scan_patterns_help', - '"Label :: regex" (case-insensitive; a plain word works too). The whole source is scanned in code, exhaustively, and the matches are handed to the model as candidates to judge in context. Use it for the pattern-like requirements of THIS rubric (words to avoid, forbidden constructs), in the language of your guidelines.' - )} - - -
- removeRubric(rubric.id)} - > - - {t('remove', 'Remove')} - -
-
- ))} -
- - - {t('add_rubric', 'Add rubric')} - -
- - {t( - 'compliance_rubrics_help', - 'Paste your thesis or internship writing guidelines. The AI checks the whole document against each rubric and returns a report.' - )} - - - {/* overleaf-lab: (b) review model selector */} - - - {t('review_model', 'Review model')} - - - - {t( - 'review_model_help', - 'Model used to run the compliance review. Pick a large-context model. Defaults to the shared chat model.' - )} - - - - {/* overleaf-lab: (c) max context tokens */} - - - {t('max_context_tokens', 'Max context tokens')} - - ) => { - const parsed = parseInt(e.target.value, 10) - if (!isNaN(parsed)) { - setMaxContextTokens(parsed) - } - }} - /> - - {t( - 'max_context_tokens_help', - 'The context window (in tokens) of the review model, as configured on your llama.cpp server (the -c value, divided by --parallel). The review refuses documents that would not fit. No auto-detection.' - )} - - - - {/* overleaf-lab: budget for the review answer itself */} - - - {t('review_max_tokens', 'Review answer budget (tokens)')} - - ) => { - const parsed = parseInt(e.target.value, 10) - if (!isNaN(parsed)) { - setReviewMaxTokens(parsed) - } - }} - /> - - {t( - 'review_max_tokens_help', - 'Upper limit for the answer of each review pass. The actual room adapts to what the document leaves free inside Max context tokens, so a large value here never blocks a long document; it only allows more thorough per-requirement analyses when there is room.' - )} - - - -
- - {/* ── Section 6: AI Prompts ── */} - {/* overleaf-lab: editable prompts behind each AI feature; empty means built-in default */} -
-
- 6 - - {t('ai_prompts', 'AI Prompts')} -
-

- {t( - 'ai_prompts_desc', - 'Customize the prompts behind each AI feature. Leave a field empty to use the built-in default.' - )} -

- - {/* overleaf-lab: (a) the three standalone prompts, each with a reset link */} - {[ - { - key: 'askAiSystemPrompt', - value: askAiSystemPrompt, - set: setAskAiSystemPrompt, - def: promptDefaults.askAiSystemPrompt, - label: t('ask_ai_behavior_prompt', 'Ask AI behavior prompt'), - help: t('ask_ai_behavior_prompt_help', 'System prompt for the selection toolbar (Ask AI / paraphrase / rewrite).'), - }, - { - key: 'errorPrompt', - value: errorPrompt, - set: setErrorPrompt, - def: promptDefaults.errorPrompt, - label: t('error_help_prompt', 'Error help prompt'), - help: t('error_help_prompt_help', 'Instructions appended when a user clicks Ask AI about a compile error.'), - }, - { - key: 'reviewSystemPrompt', - value: reviewSystemPrompt, - set: setReviewSystemPrompt, - def: promptDefaults.reviewSystemPrompt, - label: t('review_system_prompt', 'Review system prompt'), - help: t('review_system_prompt_help', 'System prompt for the whole-document compliance review.'), - }, - ].map(field => ( -
- - - {field.label} - - ) => - field.set(e.target.value) - } - style={{ fontFamily: 'monospace', fontSize: '0.8125rem' }} - /> - -
- - {field.help} - - field.set(field.def || '')} - style={{ padding: 0, fontSize: '0.8125rem' }} - > - - {t('reset_to_default', 'Reset to default')} - -
-
- ))} - - {/* overleaf-lab: (b) collapsible Ask AI action templates, one textarea per action */} -
- setShowActions(v => !v)} - style={{ padding: 0, fontSize: '0.875rem' }} - > - - {t('ask_ai_action_templates', 'Ask AI action templates')} - - - {showActions && ( -
- - {t( - 'ask_ai_action_help', - 'Each template runs on the selected text. Use {{selection}} where the selected text should be inserted; if omitted, it is appended.' - )} - - {['paraphrase', 'academic', 'concise', 'punchy', 'split', 'join', 'summarize', 'explain', 'title', 'abstract'].map(key => ( -
- - - {t(`ask_ai_action_${key}`, key.charAt(0).toUpperCase() + key.slice(1))} - - ) => { - const value = e.target.value - setAskAiActionPrompts(prev => ({ ...prev, [key]: value })) - }} - style={{ fontFamily: 'monospace', fontSize: '0.8125rem' }} - /> - -
- { - const def = promptDefaults.askAiActionPrompts?.[key] || '' - setAskAiActionPrompts(prev => ({ ...prev, [key]: def })) - }} - style={{ padding: 0, fontSize: '0.8125rem' }} - > - - {t('reset_to_default', 'Reset to default')} - -
-
- ))} -
- )} -
-
- - {/* ── Notifications ── */} - {showSuccess && ( -
- -
- )} - {isError && ( -
- -
+ {apiTypeFromEnv && ( + + + {t( + 'llm_type_from_env', + 'Inherited from the LLM_API_TYPE environment variable. Saving here stores it in the admin settings file.' )} + + )} + - {/* ── Save Button ── */} - + + {t('llm_api_key', 'API Key')} + + ) => + setLlmApiKey(e.target.value) + } + placeholder={ + hasStoredKey + ? t('llm_api_key_placeholder_stored', '•••••••• (stored — leave blank to keep)') + : t('llm_api_key_placeholder', 'Paste your API key here') + } + /> + {hasStoredKey && !llmApiKey && ( + + + {t('llm_api_key_stored', 'An API key is already stored. Leave blank to keep it.')} + + )} + + + {t('llm_api_key_optional_local', 'Leave blank for a local server with no auth (e.g. a llama.cpp server).')} + + + +
+ + + {t('test_connection', 'Test Connection')} + + {testStatus === 'success' && ( + + + {t('connection_successful', 'Connection successful')} + + )} + {testStatus === 'error' && ( + + + {t('connection_failed', 'Connection failed — check URL and key')} + + )} +
+
+ + {/* ── Section 3: Model Selection ── */} +
+
+ 3 + + {t('model_selection', 'Model Selection')} + {allModels.length > 0 && ( + + {allowedModels.filter(m => allModels.includes(m)).length}/{allModels.length} {t('selected', 'selected')} + + )} +
+

+ {t( + 'model_selection_desc', + 'Scan the API for available models, then choose which ones users can access.' + )} +

+ +
0 ? '1rem' : 0 }}> + + + {t('scan_for_models', 'Scan for Models')} + + {scanStatus === 'success' && ( + + + {t('scan_found_models', `Found ${availableModels.length} model(s)`)} + + )} + {scanStatus === 'error' && ( + + + {t('scan_failed', 'Scan failed — check connection first')} + + )} + {!canConnect && scanStatus === null && ( + + {t('configure_api_first', 'Configure the API connection above first')} + + )} +
+ + {allModels.length > 0 && ( + <> +
+ {allModels.map((model, idx) => ( + + ))} +
+
+ setAllowedModels([...allModels])} + style={{ padding: 0, fontSize: '0.8125rem' }} + > + {t('select_all', 'Select all')} + + | + setAllowedModels([])} + style={{ padding: 0, fontSize: '0.8125rem' }} + > + {t('unselect_all', 'Unselect all')} + +
+ + )} + + {/* overleaf-lab: admin picks the single shared inline-completion model */} + 0 ? '1rem' : 0 }}> + + {t('inline_completion_model', 'Inline completion model')} + + + + + {completionModel + ? completionModel === '__disabled__' + ? t( + 'completion_disabled_shared', + 'Disabled (only users with their own API key)' + ) + : completionModel + : t('auto_first_allowed_model', 'Auto (first allowed model)')} + + + + + setCompletionModel('')} + > + {t('auto_first_allowed_model', 'Auto (first allowed model)')} + + + {/* overleaf-lab: turn off shared autocomplete; users can still use their own API key */} + setCompletionModel('__disabled__')} + > + {t( + 'completion_disabled_shared', + 'Disabled (only users with their own API key)' + )} + + + {allModels.map(model => ( + setCompletionModel(model)} > - - {t('save_settings', 'Save Settings')} - - -
- - + {model} + + ))} + + + + {t( + 'inline_completion_model_admin_help', + 'Model used for inline autocomplete on the shared backend. Can differ from the chat models. Set to Disabled to turn off shared autocomplete (users with their own API key still get it).' + )} + + +
+ + {/* ── Section 4: System Prompt ── */} +
+
+ 4 + + {t('system_prompt', 'System Prompt')} +
+

+ {t( + 'system_prompt_desc', + 'This prompt is prepended to every AI conversation. Use it to customize the assistant\'s behavior for your organization.' + )} +

+ + + ) => + setSystemPrompt(e.target.value) + } + placeholder={t( + 'llm_system_prompt_placeholder', + 'You are a helpful LaTeX assistant...' + )} + maxLength={4000} + style={{ fontFamily: 'monospace', fontSize: '0.8125rem' }} + /> + +
+ + {systemPrompt.length}/4000 {t('characters', 'characters')} + + setSystemPrompt(DEFAULT_SYSTEM_PROMPT)} + style={{ padding: 0, fontSize: '0.8125rem' }} + > + + {t('reset_to_default', 'Reset to default')} + +
+
+ + {/* ── Section 5: Compliance Review ── */} +
+
+ 5 + + {t('compliance_review', 'Compliance Review')} +
+

+ {t( + 'compliance_review_desc', + 'Configure the document compliance review: the guideline rubrics users can check against, the model that runs the review, and the maximum context size.' + )} +

+ + {/* overleaf-lab: (a) rubrics editor */} + {complianceRubrics.length === 0 && ( +

+ {t('no_rubrics_yet', 'No rubrics yet. Add one to enable the compliance review for users.')} +

+ )} + {complianceRubrics.map(rubric => ( +
+ + + {t('rubric_name', 'Rubric name')} + + ) => + updateRubric(rubric.id, 'name', e.target.value) + } + placeholder={t('rubric_name_placeholder', 'e.g. Thesis writing guidelines')} + /> + + + + {t('rubric_guidelines', 'Guidelines')} + + ) => + updateRubric(rubric.id, 'guidelines', e.target.value) + } + style={{ fontFamily: 'monospace', fontSize: '0.8125rem' }} + /> + + {/* overleaf-lab: per-rubric mechanical scans; policy + patterns live next to the guidelines they verify */} + + + {t('rubric_scan_patterns', 'Scan patterns (optional, one per line)')} + + ) => + updateRubric(rubric.id, 'scanPatterns', e.target.value) + } + placeholder={'First person :: (? + + {t( + 'rubric_scan_patterns_help', + '"Label :: regex" (case-insensitive; a plain word works too). The whole source is scanned in code, exhaustively, and the matches are handed to the model as candidates to judge in context. Use it for the pattern-like requirements of THIS rubric (words to avoid, forbidden constructs), in the language of your guidelines.' + )} + + +
+ removeRubric(rubric.id)} + > + + {t('remove', 'Remove')} + +
+
+ ))} +
+ + + {t('add_rubric', 'Add rubric')} + +
+ + {t( + 'compliance_rubrics_help', + 'Paste your thesis or internship writing guidelines. The AI checks the whole document against each rubric and returns a report.' + )} + + + {/* overleaf-lab: (b) review model selector */} + + + {t('review_model', 'Review model')} + + + + + {reviewModel + ? reviewModel + : t('review_model_shared_default', 'Shared chat model (default)')} + + + + + setReviewModel('')} + > + {t('review_model_shared_default', 'Shared chat model (default)')} + + + {allModels.map(model => ( + setReviewModel(model)} + > + {model} + + ))} + + + + {t( + 'review_model_help', + 'Model used to run the compliance review. Pick a large-context model. Defaults to the shared chat model.' + )} + + + + {/* overleaf-lab: (c) max context tokens */} + + + {t('max_context_tokens', 'Max context tokens')} + + ) => { + const parsed = parseInt(e.target.value, 10) + if (!isNaN(parsed)) { + setMaxContextTokens(parsed) + } + }} + /> + + {t( + 'max_context_tokens_help', + 'The context window (in tokens) of the review model, as configured on your llama.cpp server (the -c value, divided by --parallel). The review refuses documents that would not fit. No auto-detection.' + )} + + + + {/* overleaf-lab: budget for the review answer itself */} + + + {t('review_max_tokens', 'Review answer budget (tokens)')} + + ) => { + const parsed = parseInt(e.target.value, 10) + if (!isNaN(parsed)) { + setReviewMaxTokens(parsed) + } + }} + /> + + {t( + 'review_max_tokens_help', + 'Upper limit for the answer of each review pass. The actual room adapts to what the document leaves free inside Max context tokens, so a large value here never blocks a long document; it only allows more thorough per-requirement analyses when there is room.' + )} + + + +
+ + {/* ── Section 6: AI Prompts ── */} + {/* overleaf-lab: editable prompts behind each AI feature; empty means built-in default */} +
+
+ 6 + + {t('ai_prompts', 'AI Prompts')} +
+

+ {t( + 'ai_prompts_desc', + 'Customize the prompts behind each AI feature. Leave a field empty to use the built-in default.' + )} +

+ + {/* overleaf-lab: (a) the three standalone prompts, each with a reset link */} + {[ + { + key: 'askAiSystemPrompt', + value: askAiSystemPrompt, + set: setAskAiSystemPrompt, + def: promptDefaults.askAiSystemPrompt, + label: t('ask_ai_behavior_prompt', 'Ask AI behavior prompt'), + help: t('ask_ai_behavior_prompt_help', 'System prompt for the selection toolbar (Ask AI / paraphrase / rewrite).'), + }, + { + key: 'errorPrompt', + value: errorPrompt, + set: setErrorPrompt, + def: promptDefaults.errorPrompt, + label: t('error_help_prompt', 'Error help prompt'), + help: t('error_help_prompt_help', 'Instructions appended when a user clicks Ask AI about a compile error.'), + }, + { + key: 'reviewSystemPrompt', + value: reviewSystemPrompt, + set: setReviewSystemPrompt, + def: promptDefaults.reviewSystemPrompt, + label: t('review_system_prompt', 'Review system prompt'), + help: t('review_system_prompt_help', 'System prompt for the whole-document compliance review.'), + }, + ].map(field => ( +
+ + + {field.label} + + ) => + field.set(e.target.value) + } + style={{ fontFamily: 'monospace', fontSize: '0.8125rem' }} + /> + +
+ + {field.help} + + field.set(field.def || '')} + style={{ padding: 0, fontSize: '0.8125rem' }} + > + + {t('reset_to_default', 'Reset to default')} + +
+
+ ))} + + {/* overleaf-lab: (b) collapsible Ask AI action templates, one textarea per action */} +
+ setShowActions(v => !v)} + style={{ padding: 0, fontSize: '0.875rem' }} + > + + {t('ask_ai_action_templates', 'Ask AI action templates')} + + + {showActions && ( +
+ + {t( + 'ask_ai_action_help', + 'Each template runs on the selected text. Use {{selection}} where the selected text should be inserted; if omitted, it is appended.' + )} + + {['paraphrase', 'academic', 'concise', 'punchy', 'split', 'join', 'summarize', 'explain', 'title', 'abstract'].map(key => ( +
+ + + {t(`ask_ai_action_${key}`, key.charAt(0).toUpperCase() + key.slice(1))} + + ) => { + const value = e.target.value + setAskAiActionPrompts(prev => ({ ...prev, [key]: value })) + }} + style={{ fontFamily: 'monospace', fontSize: '0.8125rem' }} + /> + +
+ { + const def = promptDefaults.askAiActionPrompts?.[key] || '' + setAskAiActionPrompts(prev => ({ ...prev, [key]: def })) + }} + style={{ padding: 0, fontSize: '0.8125rem' }} + > + + {t('reset_to_default', 'Reset to default')} + +
+
+ ))} +
+ )} +
+
+ + {/* ── Notifications ── */} + {showSuccess && ( +
+ +
+ )} + {isError && ( +
+ +
+ )} + + {/* ── Save Button ── */} + + + {t('save_settings', 'Save Settings')} + +
) } diff --git a/services/web/modules/llm/frontend/stylesheets/llm-settings.scss b/services/web/modules/llm/frontend/stylesheets/llm-settings.scss new file mode 100644 index 0000000000..4ab192c1ae --- /dev/null +++ b/services/web/modules/llm/frontend/stylesheets/llm-settings.scss @@ -0,0 +1,107 @@ +.llm-settings { + max-width: 800px; + margin: 0 auto; +} + +.llm-settings-header { + margin-bottom: 2rem; + + &-title { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; + } + + &-desc { + color: var(--content-secondary, #6c757d); + margin: 0; + } +} + +.llm-settings-section { + padding: 1.5rem; + border-radius: 8px; + border: 1px solid var(--border-color-01, #dee2e6); + background-color: var(--bg-light-primary, #fff); + margin-bottom: 1.25rem; + + &-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.25rem; + font-size: 1.1rem; + font-weight: 600; + + .llm-settings-section-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + border-radius: 50%; + background-color: var(--bg-accent-01); + color: #fff; + font-size: 0.75rem; + font-weight: 700; + flex-shrink: 0; + } + } + + &-desc { + color: var(--content-secondary, #6c757d); + font-size: 0.875rem; + margin-bottom: 1.25rem; + } + + &-body { + border: 1px solid var(--border-color-01, #dee2e6); + border-radius: 6px; + overflow: hidden; + } + + .dropdown { + width: 100%; + } + .dropdown-toggle { + width: 100%; + font-size: var(--font-size-03); + font-weight: 100; + border: 1px solid var(--border-primary); + border-radius: var(--bs-border-radius); + padding: 0.4rem 0.5rem; + display: flex; + align-items: center; + justify-content: flex-start; + + &::after { + margin-left: auto; + } + } + .dropdown-menu { + width: 100%; + .dropdown-item { + font-size: var(--font-size-03); + } + } + &-status { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.8125rem; + font-weight: 500; + + &-success { + color: var(--green-60, #198754); + } + + &-error { + color: var(--red-60, #dc3545); + } + } + + &-status-icon { + font-size: 1rem; + } +}