From f628081f902d047bae46e84bbf527f79aa8bb938 Mon Sep 17 00:00:00 2001 From: David Rotermund Date: Sat, 4 Apr 2026 01:01:28 +0000 Subject: [PATCH] LLM: Initial files --- .../app/src/infrastructure/ExpressLocals.mjs | 4 +- services/web/app/src/models/User.mjs | 5 + .../web/app/views/layout/navbar-marketing.pug | 4 + services/web/config/settings.defaults.js | 44 +- .../ide-react/context/rail-context.tsx | 1 + .../components/review-tooltip-menu.tsx | 32 +- .../components/navbar/account-menu-items.tsx | 5 + .../shared/components/navbar/admin-menu.tsx | 6 + services/web/locales/en.json | 3 + .../llm/app/src/LLMAdminController.mjs | 218 +++++ .../modules/llm/app/src/LLMChatController.mjs | 460 +++++++++ .../web/modules/llm/app/src/LLMRouter.mjs | 111 +++ .../llm/app/src/LLMSettingsController.mjs | 212 ++++ .../llm/app/views/llm-admin-settings.pug | 17 + .../modules/llm/app/views/llm-settings.pug | 14 + .../js/components/llm-admin-settings-page.tsx | 518 ++++++++++ .../frontend/js/components/llm-chat-pane.tsx | 231 +++++ .../js/components/llm-chat-toggle-button.tsx | 49 + .../frontend/js/components/llm-completion.ts | 666 +++++++++++++ .../frontend/js/components/llm-rail-pane.tsx | 27 + .../js/components/llm-settings-page.tsx | 38 + .../js/components/llm-settings-section.tsx | 243 +++++ .../llm-source-editor-component.tsx | 46 + .../frontend/js/components/llm-toolbar.tsx | 903 ++++++++++++++++++ .../pdf-log-entry-ask-ai-button.tsx | 111 +++ .../js/extensions/llm-inline-completion.ts | 6 + .../frontend/js/hooks/use-llm-chat-pane.ts | 21 + .../llm/frontend/js/hooks/use-llm-chat.ts | 291 ++++++ .../js/pages/admin/llm-admin-settings.tsx | 8 + .../frontend/js/pages/user/llm-settings.tsx | 8 + .../llm/frontend/stylesheets/llm-chat.scss | 666 +++++++++++++ services/web/modules/llm/index.mjs | 51 + services/web/types/exposed-settings.ts | 1 + 33 files changed, 5003 insertions(+), 17 deletions(-) create mode 100644 services/web/modules/llm/app/src/LLMAdminController.mjs create mode 100644 services/web/modules/llm/app/src/LLMChatController.mjs create mode 100644 services/web/modules/llm/app/src/LLMRouter.mjs create mode 100644 services/web/modules/llm/app/src/LLMSettingsController.mjs create mode 100644 services/web/modules/llm/app/views/llm-admin-settings.pug create mode 100644 services/web/modules/llm/app/views/llm-settings.pug create mode 100644 services/web/modules/llm/frontend/js/components/llm-admin-settings-page.tsx create mode 100644 services/web/modules/llm/frontend/js/components/llm-chat-pane.tsx create mode 100644 services/web/modules/llm/frontend/js/components/llm-chat-toggle-button.tsx create mode 100644 services/web/modules/llm/frontend/js/components/llm-completion.ts create mode 100644 services/web/modules/llm/frontend/js/components/llm-rail-pane.tsx create mode 100644 services/web/modules/llm/frontend/js/components/llm-settings-page.tsx create mode 100644 services/web/modules/llm/frontend/js/components/llm-settings-section.tsx create mode 100644 services/web/modules/llm/frontend/js/components/llm-source-editor-component.tsx create mode 100644 services/web/modules/llm/frontend/js/components/llm-toolbar.tsx create mode 100644 services/web/modules/llm/frontend/js/components/pdf-log-entry-ask-ai-button.tsx create mode 100644 services/web/modules/llm/frontend/js/extensions/llm-inline-completion.ts create mode 100644 services/web/modules/llm/frontend/js/hooks/use-llm-chat-pane.ts create mode 100644 services/web/modules/llm/frontend/js/hooks/use-llm-chat.ts create mode 100644 services/web/modules/llm/frontend/js/pages/admin/llm-admin-settings.tsx create mode 100644 services/web/modules/llm/frontend/js/pages/user/llm-settings.tsx create mode 100644 services/web/modules/llm/frontend/stylesheets/llm-chat.scss create mode 100644 services/web/modules/llm/index.mjs diff --git a/services/web/app/src/infrastructure/ExpressLocals.mjs b/services/web/app/src/infrastructure/ExpressLocals.mjs index de214c9b3e..05d5061060 100644 --- a/services/web/app/src/infrastructure/ExpressLocals.mjs +++ b/services/web/app/src/infrastructure/ExpressLocals.mjs @@ -59,7 +59,7 @@ async function loadManifest() { webpackManifest = {} } } -function loadManifestFromWebpackDevServer(done = function () {}) { +function loadManifestFromWebpackDevServer(done = function () { }) { fetchJson(new URL(`/manifest.json`, Settings.apis.webpack.url), { headers: { Host: 'localhost', @@ -422,6 +422,8 @@ export default async function (webRouter, privateApiRouter, publicApiRouter) { enablePandocConversions: Settings.enablePandocConversions, mixpanelLabsToken: Settings.labs?.enable && Settings.analytics?.mixpanel?.labsToken, + llmAllowUserSettings: Settings.llm?.allowUserSettings ?? false, + llmEnabled: Settings.llm?.enabled ?? false, } next() }) diff --git a/services/web/app/src/models/User.mjs b/services/web/app/src/models/User.mjs index 24ddeda55c..4de8e68af5 100644 --- a/services/web/app/src/models/User.mjs +++ b/services/web/app/src/models/User.mjs @@ -249,6 +249,11 @@ export const UserSchema = new Schema( us: { type: String }, uk: { type: String }, }, + // LLM module: per-user AI assistant settings + useOwnLLMSettings: { type: Boolean, default: false }, + llmApiKey: { type: String, default: '' }, + llmModelName: { type: String, default: '' }, + llmApiUrl: { type: String, default: '' }, }, { minimize: false } ) diff --git a/services/web/app/views/layout/navbar-marketing.pug b/services/web/app/views/layout/navbar-marketing.pug index 61ff0728ac..fa2cfd3543 100644 --- a/services/web/app/views/layout/navbar-marketing.pug +++ b/services/web/app/views/layout/navbar-marketing.pug @@ -75,6 +75,8 @@ nav.navbar.navbar-default.navbar-main.navbar-expand-lg( +dropdown-menu-link-item(href='/admin/survey') Manage Surveys if canDisplayScriptLogMenu +dropdown-menu-link-item(href='/admin/script-logs') View Script Logs + if settings.llm && settings.llm.enabled + +dropdown-menu-link-item(href='/admin/llm/settings') LLM Settings // loop over header_extras each item in nav.header_extras @@ -207,6 +209,8 @@ nav.navbar.navbar-default.navbar-main.navbar-expand-lg( .disabled.dropdown-item #{getSessionUser().email} +dropdown-menu-divider +dropdown-menu-link-item(href='/user/settings') #{translate('account_settings')} + if settings.llm && settings.llm.allowUserSettings + +dropdown-menu-link-item(href='/user/llm-settings') AI Settings if nav.showSubscriptionLink +dropdown-menu-link-item(href='/user/subscription') #{translate('subscription')} +dropdown-menu-divider diff --git a/services/web/config/settings.defaults.js b/services/web/config/settings.defaults.js index 673070b461..8e1f900884 100644 --- a/services/web/config/settings.defaults.js +++ b/services/web/config/settings.defaults.js @@ -219,18 +219,16 @@ module.exports = { // options incase you want to run some services on remote hosts. apis: { web: { - url: `http://${ - process.env.WEB_API_HOST || process.env.WEB_HOST || '127.0.0.1' - }:${process.env.WEB_API_PORT || process.env.WEB_PORT || 3000}`, + url: `http://${process.env.WEB_API_HOST || process.env.WEB_HOST || '127.0.0.1' + }:${process.env.WEB_API_PORT || process.env.WEB_PORT || 3000}`, user: httpAuthUser, pass: httpAuthPass, }, documentupdater: { - url: `http://${ - process.env.DOCUPDATER_HOST || + url: `http://${process.env.DOCUPDATER_HOST || process.env.DOCUMENT_UPDATER_HOST || '127.0.0.1' - }:3003`, + }:3003`, }, geoIpLookup: { cacheSize: intFromEnv('GEO_IP_LOOKUP_CACHE_SIZE', 10_000), @@ -298,8 +296,7 @@ module.exports = { v1_history: { url: process.env.V1_HISTORY_URL || - `http://${process.env.V1_HISTORY_HOST || '127.0.0.1'}:${ - process.env.V1_HISTORY_PORT || '3100' + `http://${process.env.V1_HISTORY_HOST || '127.0.0.1'}:${process.env.V1_HISTORY_PORT || '3100' }/api`, urlForGitBridge: process.env.V1_HISTORY_URL_FOR_GIT_BRIDGE, user: process.env.V1_HISTORY_USER || 'staging', @@ -859,7 +856,7 @@ module.exports = { .filter(x => x !== ''), trustedUsersRegex: process.env.CAPTCHA_TRUSTED_USERS_REGEX ? // Enforce matching of the entire input. - new RegExp(`^${process.env.CAPTCHA_TRUSTED_USERS_REGEX}$`) + new RegExp(`^${process.env.CAPTCHA_TRUSTED_USERS_REGEX}$`) : null, disabled: { invite: true, @@ -1063,10 +1060,25 @@ module.exports = { ), ], contactUsModal: [], - sourceEditorExtensions: [], + sourceEditorExtensions: [ + Path.resolve( + __dirname, + '../modules/llm/frontend/js/extensions/llm-inline-completion' + ), + ], sourceEditorVisualExtensions: [], - sourceEditorComponents: [], - pdfLogEntryHeaderActionComponents: [], + sourceEditorComponents: [ + Path.resolve( + __dirname, + '../modules/llm/frontend/js/components/llm-source-editor-component' + ), + ], + pdfLogEntryHeaderActionComponents: [ + Path.resolve( + __dirname, + '../modules/llm/frontend/js/components/pdf-log-entry-ask-ai-button' + ), + ], pdfLogEntryComponents: [], pdfLogEntriesComponents: [], pdfPreviewPromotions: [], @@ -1198,7 +1210,12 @@ module.exports = { '../modules/reference-picker/frontend/reference-index/advanced-reference-index.ts' ), ], - railEntries: [], + railEntries: [ + Path.resolve( + __dirname, + '../modules/llm/frontend/js/components/llm-rail-pane' + ), + ], railPopovers: [], railActions: [], railModals: [], @@ -1221,6 +1238,7 @@ module.exports = { 'git-bridge', 'github-sync', 'zotero', + 'llm', ], viewIncludes: {}, diff --git a/services/web/frontend/js/features/ide-react/context/rail-context.tsx b/services/web/frontend/js/features/ide-react/context/rail-context.tsx index 697fe9916f..328be11c2b 100644 --- a/services/web/frontend/js/features/ide-react/context/rail-context.tsx +++ b/services/web/frontend/js/features/ide-react/context/rail-context.tsx @@ -25,6 +25,7 @@ export type RailTabKey = | 'full-project-search' | 'dimensions' | 'workbench' + | 'llm-chat' export type RailModalKey = | 'keyboard-shortcuts' diff --git a/services/web/frontend/js/features/review-panel/components/review-tooltip-menu.tsx b/services/web/frontend/js/features/review-panel/components/review-tooltip-menu.tsx index cd070d46dc..6a7fa30157 100644 --- a/services/web/frontend/js/features/review-panel/components/review-tooltip-menu.tsx +++ b/services/web/frontend/js/features/review-panel/components/review-tooltip-menu.tsx @@ -39,6 +39,7 @@ import useEventListener from '@/shared/hooks/use-event-listener' import useReviewPanelLayout from '../hooks/use-review-panel-layout' import { usePermissionsContext } from '@/features/ide-react/context/permissions-context' import { sendMB } from '@/infrastructure/event-tracking' +import getMeta from '@/utils/meta' const EDIT_MODE_SWITCH_WIDGET_HEIGHT = 40 const CM_LINE_RIGHT_PADDING = 8 @@ -127,13 +128,13 @@ const ReviewTooltipMenu: FC = () => { } return ReactDOM.createPortal( - , + setShow(false)} />, tooltipView.dom ) } -const ReviewTooltipMenuContent = memo<{ onAddComment: () => void }>( - function ReviewTooltipMenuContent({ onAddComment }) { +const ReviewTooltipMenuContent = memo<{ onAddComment: () => void; onHide: () => void }>( + function ReviewTooltipMenuContent({ onAddComment, onHide }) { const { t } = useTranslation() const view = useCodeMirrorViewContext() const state = useCodeMirrorStateContext() @@ -148,6 +149,8 @@ const ReviewTooltipMenuContent = memo<{ onAddComment: () => void }>( >() const [visible, setVisible] = useState(false) + const llmEnabled = !!(getMeta('ol-ExposedSettings') as any)?.llmEnabled + const handleAddCommentClick = useCallback(() => { sendMB('add-comment', { location: 'tooltip', @@ -271,6 +274,29 @@ const ReviewTooltipMenuContent = memo<{ onAddComment: () => void }>( {t('add_comment')} + {llmEnabled && ( + <> +
+ + + )} {showChangesButtons && ( <>
diff --git a/services/web/frontend/js/shared/components/navbar/account-menu-items.tsx b/services/web/frontend/js/shared/components/navbar/account-menu-items.tsx index 9cff9d8dd3..466a7544e1 100644 --- a/services/web/frontend/js/shared/components/navbar/account-menu-items.tsx +++ b/services/web/frontend/js/shared/components/navbar/account-menu-items.tsx @@ -32,6 +32,11 @@ export function AccountMenuItems({ {t('account_settings')} + {getMeta('ol-ExposedSettings')?.llmAllowUserSettings ? ( + + {t('ai_settings', 'AI Settings')} + + ) : null} {showSubscriptionLink ? ( {t('subscription')} diff --git a/services/web/frontend/js/shared/components/navbar/admin-menu.tsx b/services/web/frontend/js/shared/components/navbar/admin-menu.tsx index d44ee6c1b3..322defcc2f 100644 --- a/services/web/frontend/js/shared/components/navbar/admin-menu.tsx +++ b/services/web/frontend/js/shared/components/navbar/admin-menu.tsx @@ -2,6 +2,7 @@ import type { DefaultNavbarMetadata } from '@/shared/components/types/default-na import NavDropdownMenu from '@/shared/components/navbar/nav-dropdown-menu' import NavDropdownLinkItem from '@/shared/components/navbar/nav-dropdown-link-item' import { useSendProjectListMB } from '@/features/project-list/components/project-list-events' +import getMeta from '@/utils/meta' export default function AdminMenu({ canDisplayAdminMenu, @@ -68,6 +69,11 @@ export default function AdminMenu({ View Script Logs ) : null} + {(getMeta('ol-ExposedSettings') as any)?.llmEnabled ? ( + + LLM Settings + + ) : null} ) } diff --git a/services/web/locales/en.json b/services/web/locales/en.json index 1c2accb783..54c8846b4c 100644 --- a/services/web/locales/en.json +++ b/services/web/locales/en.json @@ -160,8 +160,10 @@ "ai_assist_in_overleaf_is_included_via_writefull_groups": "AI Assist in Overleaf is included as part of your group or organization’s Writefull subscription. To make changes you’ll need to speak to your subscription admin", "ai_assist_in_overleaf_is_included_via_writefull_individual": "AI Assist in Overleaf is included as part of your Writefull subscription. You can cancel or manage your access to AI Assist in your Writefull subscription settings.", "ai_assist_unavailable": "AI features are now included in every paid plan. See the <0>pricing page for what each plan includes.", + "ai_assist_unavailable_due_to_subscription_type": "We’re sorry—it looks like AI Assist isn’t available to you just yet due to your current subscription type.", "ai_assistance": "AI assistance", "ai_assistant": "AI Assistant", + "ai_assistant_description": "Get help with LaTeX syntax, formatting, and troubleshooting", "ai_assistant_disabled": "AI Assistant disabled", "ai_assistant_explanation": "A LaTeX-fluent AI Assistant built into your editor.", "ai_can_make_mistakes": "AI can make mistakes. Review fixes before you apply them.", @@ -240,6 +242,7 @@ "as_a_member_of_sso_required": "As a member of __institutionName__, you must log in to __appName__ through your institution.", "as_email": "as __email__", "ascending": "Ascending", + "ask_ai_about_error": "Ask AI about this error", "ask_proj_owner_to_add_you_as_github_collaborator": "You don’t have permission to push to the linked repository <0>__repoFullName__. Ask the owner of the project (<1>__projectOwnerEmail__) to add you as a collaborator to this repository.", "ask_proj_owner_to_export_to_github": "Ask the owner of the project (<0>__projectOwnerEmail__) to export the project to a Git repository and add you as a collaborator.", "ask_proj_owner_to_unlink_from_current_github": "Ask the owner of the project (<0>__projectOwnerEmail__) to unlink the project from the current git repository and create a connection to a different repository.", diff --git a/services/web/modules/llm/app/src/LLMAdminController.mjs b/services/web/modules/llm/app/src/LLMAdminController.mjs new file mode 100644 index 0000000000..a55e3857db --- /dev/null +++ b/services/web/modules/llm/app/src/LLMAdminController.mjs @@ -0,0 +1,218 @@ +import logger from '@overleaf/logger' +import { promises as fs } from 'fs' +import path from 'path' +import { expressify } from '@overleaf/promise-utils' + +// 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' + +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 {} + } +} + +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 + } +} + +async function adminSettingsPage(req, res) { + const settings = await readAdminSettings() + const pugPath = new URL('../../app/views/llm-admin-settings.pug', import.meta.url).pathname + res.render(pugPath, { + systemPrompt: settings.systemPrompt || '', + llmApiUrl: settings.llmApiUrl || '', + hasLlmApiKey: !!settings.llmApiKey, + allowedModels: settings.allowedModels || [], + }) +} + +async function getAdminSettings(req, res) { + const settings = await readAdminSettings() + res.json({ + systemPrompt: settings.systemPrompt || '', + llmApiUrl: settings.llmApiUrl || '', + hasLlmApiKey: !!settings.llmApiKey, + allowedModels: settings.allowedModels || [], + }) +} + +async function saveAdminSettings(req, res) { + const { systemPrompt, llmApiUrl, llmApiKey, allowedModels } = req.body + + 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' }) + } + + 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 = await readAdminSettings() + const updatedSettings = { + ...existing, + systemPrompt, + llmApiUrl: typeof llmApiUrl === 'string' ? llmApiUrl : (existing.llmApiUrl || ''), + allowedModels: Array.isArray(allowedModels) ? allowedModels : existing.allowedModels || [], + } + + if (typeof llmApiKey === 'string' && llmApiKey.trim().length > 0) { + updatedSettings.llmApiKey = llmApiKey.trim() + } + + await writeAdminSettings(updatedSettings) + logger.info({ + length: systemPrompt.length, + llmApiUrl: !!updatedSettings.llmApiUrl, + 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 +} + +export async function getAdminLLMSettings() { + const settings = await readAdminSettings() + return { + llmApiUrl: settings.llmApiUrl || null, + llmApiKey: settings.llmApiKey || null, + allowedModels: Array.isArray(settings.allowedModels) + ? settings.allowedModels + : [], + } +} + +async function checkAdminLLMConnection(req, res) { + const { apiUrl, apiKey } = req.body + const adminSettings = await getAdminLLMSettings() + const effectiveUrl = apiUrl || adminSettings.llmApiUrl + const effectiveKey = apiKey || adminSettings.llmApiKey + + if (!effectiveUrl || !effectiveKey) { + return res.status(400).json({ + success: false, + error: 'LLM API URL and API key are required', + }) + } + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), 30000) + + try { + const response = await fetch(`${effectiveUrl}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${effectiveKey}`, + }, + body: JSON.stringify({ + model: 'qwen3-32b', + messages: [{ role: 'user', content: 'Test connection' }], + max_tokens: 1, + }), + signal: controller.signal, + }) + + clearTimeout(timeout) + + 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' }) + } +} + +async function scanAdminModels(req, res) { + const { apiUrl, apiKey } = req.query + const adminSettings = await getAdminLLMSettings() + const llmApiUrl = apiUrl || adminSettings.llmApiUrl + const llmApiKey = apiKey || adminSettings.llmApiKey + + if (!llmApiUrl || !llmApiKey) { + return res.status(400).json({ + success: false, + error: 'Admin LLM API URL and API key must be configured first', + }) + } + + try { + const response = await fetch(`${llmApiUrl}/models`, { + method: 'GET', + headers: { + Authorization: `Bearer ${llmApiKey}`, + }, + }) + + 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 data = await response.json() + const ids = Array.isArray(data?.data) + ? data.data.map(entry => String(entry.id)) + : [] + + 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' }) + } +} + +export default { + 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 new file mode 100644 index 0000000000..abc391eeb7 --- /dev/null +++ b/services/web/modules/llm/app/src/LLMChatController.mjs @@ -0,0 +1,460 @@ +import logger from '@overleaf/logger' +import fetch from 'node-fetch' +import { AbortController } from 'abort-controller' +import { expressify } from '@overleaf/promise-utils' +import SessionManager from '../../../../app/src/Features/Authentication/SessionManager.mjs' +import { User } from '../../../../app/src/models/User.mjs' +import Settings from '@overleaf/settings' +import { getSystemPrompt, getAdminLLMSettings } from './LLMAdminController.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() +} + +// Parse available models from admin settings or environment variable +async function getAvailableModels() { + if (Settings.llm && !Settings.llm.enabled) { + logger.debug({ llmEnabled: Settings.llm.enabled }, '[LLM] getAvailableModels: LLM disabled') + return [] + } + + const adminSettings = await getAdminLLMSettings() + let modelsList = Array.isArray(adminSettings.allowedModels) + ? adminSettings.allowedModels.filter(m => typeof m === 'string' && m.trim().length > 0) + : [] + + if (modelsList.length === 0) { + const modelsEnv = process.env.LLM_AVAILABLE_MODELS || process.env.LLM_MODEL_NAME + logger.debug( + { + LLM_AVAILABLE_MODELS: process.env.LLM_AVAILABLE_MODELS, + LLM_MODEL_NAME: process.env.LLM_MODEL_NAME, + resolved: modelsEnv, + }, + '[LLM] getAvailableModels: reading env' + ) + + if (modelsEnv) { + modelsList = modelsEnv + .split(',') + .map(m => m.trim()) + .filter(m => m.length > 0) + } + } + + if (modelsList.length === 0) { + return [] + } + + const result = modelsList.map((id, index) => ({ + id, + name: id.replace(/-/g, ' ').toUpperCase(), + isDefault: index === 0, + })) + + logger.debug({ count: result.length, models: result.map(m => m.id) }, '[LLM] getAvailableModels: parsed models') + return result +} + +async function getModels(req, res) { + const userId = SessionManager.getLoggedInUserId(req.session) + const projectId = req.params.Project_id + + logger.debug( + { projectId, userId, llmSettings: Settings.llm }, + '[LLM] getModels: request received' + ) + + try { + if (Settings.llm && !Settings.llm.enabled) { + logger.debug({}, '[LLM] getModels: LLM disabled, returning empty') + return res.json({ models: [] }) + } + + const models = [] + + // 1. Add server-wide models from admin settings/env + const serverModels = await getAvailableModels() + models.push(...serverModels) + + // 2. Add user's personal LLM model if configured and activated + if (userId && Settings.llm && Settings.llm.allowUserSettings) { + try { + const user = await User.findById( + userId, + 'useOwnLLMSettings llmModelName llmApiUrl llmApiKey' + ) + + if ( + user && + user.useOwnLLMSettings && + user.llmModelName && + user.llmApiUrl && + user.llmApiKey + ) { + const personalModel = { + id: `personal-${user.llmModelName}`, + name: `${user.llmModelName} (🔒 Personal)`, + isDefault: false, + isPersonal: true, + label: 'Private', + } + models.push(personalModel) + } + } catch (error) { + logger.warn( + { userId, projectId, err: error }, + '[LLM] Error fetching user LLM settings' + ) + } + } + + logger.debug( + { count: models.length, modelIds: models.map(m => m.id) }, + '[LLM] getModels: returning models' + ) + res.json({ models }) + } catch (error) { + logger.error( + { userId, projectId, err: error }, + '[LLM] Error fetching available models' + ) + res.status(500).json({ + success: false, + error: 'Failed to fetch available models', + }) + } +} + +async function chat(req, res) { + const { messages, model } = req.body + const projectId = req.params.Project_id + const userId = SessionManager.getLoggedInUserId(req.session) + + logger.debug( + { + projectId, + userId, + model, + messageCount: Array.isArray(messages) ? messages.length : 'invalid', + isPersonalModel: model?.startsWith('personal-'), + }, + '[LLM] chat: request received' + ) + + if (!messages || !Array.isArray(messages)) { + return res.status(400).json({ error: 'Invalid messages format' }) + } + + if (Settings.llm && !Settings.llm.enabled) { + return res.status(503).json({ error: 'LLM service is disabled' }) + } + + const isPersonalModel = model && model.startsWith('personal-') + + const adminLlmSettings = await getAdminLLMSettings() + let llmApiUrl = adminLlmSettings.llmApiUrl || process.env.LLM_API_URL + let llmApiKey = adminLlmSettings.llmApiKey || process.env.LLM_API_KEY + + if (isPersonalModel && userId) { + try { + const user = await User.findById( + userId, + 'useOwnLLMSettings llmApiUrl llmApiKey llmModelName' + ) + if ( + user && + user.useOwnLLMSettings && + user.llmApiUrl && + user.llmApiKey + ) { + llmApiUrl = user.llmApiUrl + llmApiKey = user.llmApiKey + } else { + return res.status(400).json({ + error: + 'Your LLM settings are incomplete. Please configure API URL, API Key, and Model Name in your account settings.', + }) + } + } catch (error) { + return res.status(500).json({ + error: 'Failed to retrieve user LLM settings', + }) + } + } else if (!isPersonalModel) { + if (!llmApiUrl || !llmApiKey) { + return res.status(503).json({ + error: + 'LLM service is not configured. Please contact your administrator or configure your own LLM settings.', + }) + } + } + + if (!llmApiUrl || !llmApiKey) { + return res.status(503).json({ error: 'LLM service is not configured' }) + } + + const modelNameForApi = isPersonalModel && model + ? model.substring('personal-'.length) + : model || 'qwen3-32b' + + const controller = new AbortController() + const timeout = setTimeout(() => { + controller.abort() + }, 300000) // 5 minutes + + try { + const llmApiFullUrl = `${llmApiUrl}/chat/completions` + + // Prepend admin-configured system prompt if set and not already present + let finalMessages = messages + const adminSystemPrompt = await getSystemPrompt() + if (adminSystemPrompt) { + const hasSystemMessage = messages.length > 0 && messages[0].role === 'system' + if (hasSystemMessage) { + finalMessages = [ + { role: 'system', content: adminSystemPrompt + '\n\n' + messages[0].content }, + ...messages.slice(1), + ] + } else { + finalMessages = [ + { role: 'system', content: adminSystemPrompt }, + ...messages, + ] + } + } + + const requestBody = { + model: modelNameForApi, + messages: finalMessages, + max_tokens: 8192, + temperature: 0.7, + } + + const startTime = Date.now() + + logger.info( + { + projectId, + model: modelNameForApi, + url: llmApiFullUrl, + isPersonalModel, + maxTokens: 8192, + }, + '[LLM] chat: sending request to LLM API' + ) + + const response = await fetch(llmApiFullUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${llmApiKey}`, + }, + 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' + ) + } + } + + 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, + }) + } +} + +async function completion(req, res) { + const projectId = req.params.Project_id + const userId = SessionManager.getLoggedInUserId(req.session) + const { cursorOffset, leftContext, rightContext, language, maxLength } = + req.body + + logger.debug( + { + projectId, + userId, + language, + maxLength, + leftContextLen: leftContext?.length, + rightContextLen: rightContext?.length, + }, + '[LLM] completion: request received' + ) + + if (!leftContext && !rightContext) { + return res.status(400).json({ success: false, error: 'No context provided' }) + } + + if (Settings.llm && !Settings.llm.enabled) { + return res.status(503).json({ success: false, error: 'LLM service is disabled' }) + } + + const adminLlmSettings = await getAdminLLMSettings() + let llmApiUrl = adminLlmSettings.llmApiUrl || process.env.LLM_API_URL + let llmApiKey = adminLlmSettings.llmApiKey || process.env.LLM_API_KEY + + // Try user settings if server-wide not configured + if ((!llmApiUrl || !llmApiKey) && userId) { + try { + const user = await User.findById( + userId, + 'useOwnLLMSettings llmApiUrl llmApiKey llmModelName' + ) + if (user && user.useOwnLLMSettings && user.llmApiUrl && user.llmApiKey) { + llmApiUrl = user.llmApiUrl + llmApiKey = user.llmApiKey + } + } catch (error) { + logger.warn({ userId, err: error }, '[LLM] Error loading user settings for completion') + } + } + + if (!llmApiUrl || !llmApiKey) { + return res.status(503).json({ success: false, error: 'LLM service is not configured' }) + } + + const completionModel = + process.env.LLM_COMPLETION_MODEL || + (process.env.LLM_MODEL_NAME || 'qwen3-32b').split(',')[0].trim() + + const controller = new AbortController() + const timeout = setTimeout(() => { + controller.abort() + }, 30000) // 30 seconds for completions + + try { + const systemPrompt = `/no_think\nYou are a text completion engine. Output ONLY the missing 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]: + +${leftContext}[CURSOR]${rightContext}` + + const response = await fetch(`${llmApiUrl}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${llmApiKey}`, + }, + body: JSON.stringify({ + model: completionModel, + messages: [ + { role: 'system', content: systemPrompt }, + { role: 'user', content: userPrompt }, + ], + max_tokens: maxLength || 60, + temperature: 0.1, + }), + signal: controller.signal, + }) + + 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' }) + } +} + +export default { + chat: expressify(chat), + getModels: expressify(getModels), + completion: expressify(completion), +} diff --git a/services/web/modules/llm/app/src/LLMRouter.mjs b/services/web/modules/llm/app/src/LLMRouter.mjs new file mode 100644 index 0000000000..90126e63f3 --- /dev/null +++ b/services/web/modules/llm/app/src/LLMRouter.mjs @@ -0,0 +1,111 @@ +import logger from '@overleaf/logger' +import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.mjs' +import AuthorizationMiddleware from '../../../../app/src/Features/Authorization/AuthorizationMiddleware.mjs' +import LLMChatController from './LLMChatController.mjs' +import LLMSettingsController from './LLMSettingsController.mjs' +import LLMAdminController from './LLMAdminController.mjs' +import Settings from '@overleaf/settings' + +export default { + apply(webRouter) { + logger.info( + { + allowUserSettings: Settings.llm?.allowUserSettings, + apiUrl: process.env.LLM_API_URL ? '(set)' : '(not set)', + apiKey: process.env.LLM_API_KEY ? '(set)' : '(not set)', + modelName: process.env.LLM_MODEL_NAME, + }, + '[LLM] Registering routes' + ) + + // Chat and model endpoints (project-scoped) + webRouter.post( + '/project/:Project_id/llm/chat', + AuthorizationMiddleware.ensureUserCanReadProject, + LLMChatController.chat + ) + logger.debug({}, '[LLM] Route registered: POST /project/:id/llm/chat') + + webRouter.get( + '/project/:Project_id/llm/models', + AuthorizationMiddleware.ensureUserCanReadProject, + LLMChatController.getModels + ) + logger.debug({}, '[LLM] Route registered: GET /project/:id/llm/models') + + // Inline completion endpoint (project-scoped) + webRouter.post( + '/project/:Project_id/llm/completion', + AuthorizationMiddleware.ensureUserCanReadProject, + LLMChatController.completion + ) + logger.debug({}, '[LLM] Route registered: POST /project/:id/llm/completion') + + // User LLM settings (only if allowed) + if (Settings.llm && Settings.llm.allowUserSettings) { + webRouter.get( + '/user/llm-settings', + AuthenticationController.requireLogin(), + LLMSettingsController.llmSettingsPage + ) + logger.debug({}, '[LLM] Route registered: GET /user/llm-settings') + } else { + logger.debug( + { allowUserSettings: Settings.llm?.allowUserSettings }, + '[LLM] Skipping /user/llm-settings route (user settings disabled)' + ) + } + + webRouter.post( + '/user/llm-settings/check', + AuthenticationController.requireLogin(), + LLMSettingsController.checkLLMConnection + ) + logger.debug({}, '[LLM] Route registered: POST /user/llm-settings/check') + + webRouter.post( + '/user/llm-settings', + AuthenticationController.requireLogin(), + LLMSettingsController.saveLLMSettings + ) + logger.debug({}, '[LLM] Route registered: POST /user/llm-settings') + + logger.info({}, '[LLM] All routes registered successfully') + + // Admin routes + webRouter.get( + '/admin/llm/settings', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + LLMAdminController.adminSettingsPage + ) + logger.debug({}, '[LLM] Route registered: GET /admin/llm/settings') + + webRouter.get( + '/admin/llm/settings/json', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + LLMAdminController.getAdminSettings + ) + logger.debug({}, '[LLM] Route registered: GET /admin/llm/settings/json') + + webRouter.post( + '/admin/llm/settings', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + LLMAdminController.saveAdminSettings + ) + logger.debug({}, '[LLM] Route registered: POST /admin/llm/settings') + + webRouter.post( + '/admin/llm/settings/check', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + LLMAdminController.checkAdminLLMConnection + ) + logger.debug({}, '[LLM] Route registered: POST /admin/llm/settings/check') + + webRouter.get( + '/admin/llm/models', + AuthorizationMiddleware.ensureUserIsSiteAdmin, + LLMAdminController.scanAdminModels + ) + logger.debug({}, '[LLM] Route registered: GET /admin/llm/models') + }, +} diff --git a/services/web/modules/llm/app/src/LLMSettingsController.mjs b/services/web/modules/llm/app/src/LLMSettingsController.mjs new file mode 100644 index 0000000000..25ee6b95a6 --- /dev/null +++ b/services/web/modules/llm/app/src/LLMSettingsController.mjs @@ -0,0 +1,212 @@ +import logger from '@overleaf/logger' +import fetch from 'node-fetch' +import { AbortController } from 'abort-controller' +import { fileURLToPath } from 'url' +import SessionManager from '../../../../app/src/Features/Authentication/SessionManager.mjs' +import { User } from '../../../../app/src/models/User.mjs' +import { expressify } from '@overleaf/promise-utils' + +const llmSettingsPugPath = fileURLToPath( + new URL('../../app/views/llm-settings.pug', import.meta.url) +) + +async function llmSettingsPage(req, res) { + const userId = SessionManager.getLoggedInUserId(req.session) + + logger.debug({ userId, pugPath: llmSettingsPugPath }, '[LLM] llmSettingsPage: rendering') + + let user = {} + try { + user = await User.findById( + userId, + 'useOwnLLMSettings llmModelName llmApiUrl llmApiKey' + ) + } catch (err) { + logger.warn({ userId, err }, '[LLM] Error loading user for settings page') + } + + const llmSettings = { + useOwnSettings: user?.useOwnLLMSettings || false, + modelName: user?.llmModelName || '', + apiUrl: user?.llmApiUrl || '', + hasApiKey: !!(user?.llmApiKey), + } + + logger.debug( + { userId, useOwnSettings: llmSettings.useOwnSettings, hasApiKey: llmSettings.hasApiKey }, + '[LLM] llmSettingsPage: user settings loaded' + ) + + res.render(llmSettingsPugPath, { user: { llmSettings } }) +} + +async function checkLLMConnection(req, res) { + const { apiUrl, apiKey: providedApiKey, modelName } = req.body + const userId = SessionManager.getLoggedInUserId(req.session) + + logger.debug( + { userId, apiUrl, modelName, hasProvidedKey: !!providedApiKey }, + '[LLM] checkLLMConnection: request received' + ) + + // If no API key provided, fall back to stored key + let apiKey = providedApiKey + if (!apiKey) { + try { + const user = await User.findById(userId, 'llmApiKey') + if (user && user.llmApiKey) { + apiKey = user.llmApiKey + logger.debug({ userId }, '[LLM] checkLLMConnection: using stored API key') + } + } catch (err) { + logger.warn({ err }, '[LLM] Could not fetch stored API key') + } + } + + if (!apiUrl || !apiKey || !modelName) { + return res.status(400).json({ error: 'Missing required parameters' }) + } + + const controller = new AbortController() + const timeout = setTimeout(() => { + controller.abort() + }, 30000) + + try { + const llmApiUrl = `${apiUrl}/chat/completions` + + const requestBody = { + model: modelName, + messages: [{ role: 'user', content: 'Test connection' }], + max_tokens: 10, + temperature: 0.7, + } + + const startTime = Date.now() + + const response = await fetch(llmApiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify(requestBody), + signal: controller.signal, + }) + + clearTimeout(timeout) + const duration = Date.now() - startTime + + logger.debug( + { userId, apiUrl, modelName, status: response.status, duration: `${duration}ms` }, + '[LLM] checkLLMConnection: LLM API responded' + ) + + if (!response.ok) { + const errorText = await response.text() + return res.status(400).json({ + success: false, + error: 'LLM connection failed', + details: errorText, + status: response.status, + }) + } + + res.json({ + success: true, + message: 'LLM connection successful', + duration: `${duration}ms`, + }) + } catch (error) { + clearTimeout(timeout) + + if (error.name === 'AbortError') { + return res.status(504).json({ + success: false, + error: 'Connection timeout', + details: 'The LLM API did not respond within 30 seconds', + }) + } + + res.status(500).json({ + success: false, + error: 'Failed to test LLM connection', + details: error.message, + }) + } +} + +async function saveLLMSettings(req, res) { + const userId = SessionManager.getLoggedInUserId(req.session) + const { useOwnLLMSettings, llmApiKey, llmModelName, llmApiUrl } = req.body + + logger.debug( + { + userId, + useOwnLLMSettings, + llmModelName, + llmApiUrl, + hasApiKey: !!llmApiKey, + }, + '[LLM] saveLLMSettings: request received' + ) + + try { + if (useOwnLLMSettings) { + const currentUser = await User.findById(userId, 'llmApiKey') + const hasExistingApiKey = currentUser && currentUser.llmApiKey + + if (!llmApiUrl || !llmModelName) { + return res.status(400).json({ + success: false, + error: + 'API URL and Model Name are required when enabling custom LLM settings', + }) + } + + if (!hasExistingApiKey && (!llmApiKey || llmApiKey.trim() === '')) { + return res.status(400).json({ + success: false, + error: + 'API Key is required when enabling custom LLM settings', + }) + } + } + + const updateData = { + useOwnLLMSettings: Boolean(useOwnLLMSettings), + llmModelName: llmModelName || '', + llmApiUrl: llmApiUrl || '', + } + + // Only update API key if a new one is provided + if (llmApiKey && llmApiKey.trim() !== '') { + updateData.llmApiKey = llmApiKey + } + + await User.updateOne({ _id: userId }, { $set: updateData }) + + logger.debug({ userId, useOwnLLMSettings }, '[LLM] saveLLMSettings: saved successfully') + + res.json({ + success: true, + message: 'LLM settings saved successfully', + }) + } catch (error) { + logger.error( + { userId, err: error }, + '[LLM] Error saving settings' + ) + + res.status(500).json({ + success: false, + error: 'Failed to save LLM settings', + }) + } +} + +export default { + llmSettingsPage: expressify(llmSettingsPage), + checkLLMConnection: expressify(checkLLMConnection), + saveLLMSettings: expressify(saveLLMSettings), +} diff --git a/services/web/modules/llm/app/views/llm-admin-settings.pug b/services/web/modules/llm/app/views/llm-admin-settings.pug new file mode 100644 index 0000000000..bdee8a42c5 --- /dev/null +++ b/services/web/modules/llm/app/views/llm-admin-settings.pug @@ -0,0 +1,17 @@ +extends ../../../../app/views/layout-react + +block entrypointVar + - entrypoint = 'modules/llm/pages/admin/llm-admin-settings' + +block vars + - isWebsiteRedesign = true + +block append meta + meta(name='ol-systemPrompt' data-type='string' content=systemPrompt) + meta(name='ol-llmApiUrl' data-type='string' content=llmApiUrl) + meta(name='ol-hasLlmApiKey' data-type='string' content=hasLlmApiKey ? 'true' : 'false') + meta(name='ol-allowedModels' data-type='string' content=(allowedModels || []).join(',')) + +block content + main#main-content.content.content-alt + #llm-admin-settings-root diff --git a/services/web/modules/llm/app/views/llm-settings.pug b/services/web/modules/llm/app/views/llm-settings.pug new file mode 100644 index 0000000000..f333beb341 --- /dev/null +++ b/services/web/modules/llm/app/views/llm-settings.pug @@ -0,0 +1,14 @@ +extends ../../../../app/views/layout-react + +block entrypointVar + - entrypoint = 'modules/llm/pages/user/llm-settings' + +block vars + - isWebsiteRedesign = true + +block append meta + meta(name='ol-user' data-type='json' content=user) + +block content + main#main-content.content.content-alt + #settings-page-root 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 new file mode 100644 index 0000000000..7b024aa9e6 --- /dev/null +++ b/services/web/modules/llm/frontend/js/components/llm-admin-settings-page.tsx @@ -0,0 +1,518 @@ +import React, { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import getMeta from '@/utils/meta' +import { postJSON } from '@/infrastructure/fetch-json' +import useAsync from '@/shared/hooks/use-async' +import OLButton from '@/shared/components/ol/ol-button' +import OLFormGroup from '@/shared/components/ol/ol-form-group' +import OLFormLabel from '@/shared/components/ol/ol-form-label' +import OLFormText from '@/shared/components/ol/ol-form-text' +import OLFormControl from '@/shared/components/ol/ol-form-control' +import OLNotification from '@/shared/components/ol/ol-notification' +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' + +const DEFAULT_SYSTEM_PROMPT = `You are an expert LaTeX debugging assistant and compiler error specialist. + +**Your Primary Role - Error Debugging:** +- Analyze LaTeX compilation errors and warnings +- Identify syntax mistakes, missing packages, and structural issues +- Explain errors in beginner-friendly language +- Provide working fixes with clear explanations + +**When a user sends a compilation error:** + +1. **Quick Summary** (1-2 sentences) + - What's wrong in plain English + +2. **The Problem** + - Explain the error clearly + - Point to the exact issue in their code + +3. **The Fix** + - Show corrected code in \`\`\`latex blocks + - Highlight what changed + +4. **Why This Happened** + - Brief explanation of the root cause + - How to prevent it in future + +**Error Analysis Guidelines:** +- The line marked with → is where the error occurred +- Look at surrounding context for clues +- Common issues: typos in commands, missing packages, unmatched braces +- Check for: \\begin without \\end, missing $, wrong package names + +**Also Helpful With:** +- General LaTeX syntax and commands +- Document structure and formatting +- Mathematical typesetting +- Bibliography and citations + +**Response Style:** +- Be concise and practical +- Use code blocks for all LaTeX examples +- Assume the user is learning LaTeX +- Focus on solving the immediate problem first + +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', + gap: '0.25rem', + fontSize: '0.8125rem', + fontWeight: 500, + color: variant === 'success' + ? 'var(--green-60, #198754)' + : '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, +} + +export default function LLMAdminSettingsPage() { + const { t } = useTranslation() + const hasStoredKey = getMeta('ol-hasLlmApiKey') === 'true' + + const [systemPrompt, setSystemPrompt] = useState( + (getMeta('ol-systemPrompt') as string) || DEFAULT_SYSTEM_PROMPT + ) + const [llmApiUrl, setLlmApiUrl] = useState( + (getMeta('ol-llmApiUrl') as string) || '' + ) + const [llmApiKey, setLlmApiKey] = useState('') + const [allowedModels, setAllowedModels] = useState( + ((getMeta('ol-allowedModels') as string) || '') + .split(',') + .map(m => m.trim()) + .filter(Boolean) + ) + const [availableModels, setAvailableModels] = useState([]) + const [scanStatus, setScanStatus] = useState(null) + const [testStatus, setTestStatus] = useState(null) + + const { + isLoading: isSaving, + isSuccess, + isError, + error, + runAsync, + } = useAsync() + + const [showSuccess, setShowSuccess] = useState(false) + useEffect(() => { + if (isSuccess) { + setShowSuccess(true) + const timer = setTimeout(() => setShowSuccess(false), 4000) + return () => clearTimeout(timer) + } + }, [isSuccess]) + + const canConnect = !!llmApiUrl && (!!llmApiKey || hasStoredKey) + + const handleSave = (e: React.FormEvent) => { + e.preventDefault() + runAsync( + postJSON('/admin/llm/settings', { + body: { + systemPrompt, + llmApiUrl, + llmApiKey, + allowedModels, + }, + }) + ).catch(() => { }) + } + + const testConnection = async () => { + setTestStatus('testing') + try { + const resp = await postJSON('/admin/llm/settings/check', { + body: { apiUrl: llmApiUrl, apiKey: llmApiKey }, + }) + if (resp.success) { + setTestStatus('success') + } else { + setTestStatus('error') + } + } catch (e) { + setTestStatus('error') + } + } + + const scanModels = async () => { + setScanStatus('scanning') + try { + const params = new URLSearchParams() + if (llmApiUrl) params.set('apiUrl', llmApiUrl) + if (llmApiKey) params.set('apiKey', llmApiKey) + const resp = await fetch(`/admin/llm/models?${params.toString()}`, { + method: 'GET', + credentials: 'same-origin', + }) + const json = await resp.json() + if (json.success && Array.isArray(json.models)) { + setAvailableModels(json.models) + setScanStatus('success') + setAllowedModels(prev => { + const combined = new Set([...prev, ...json.models]) + return Array.from(combined) + }) + } else { + setScanStatus('error') + } + } catch { + setScanStatus('error') + } + } + + const toggleAllowedModel = (model: string) => { + setAllowedModels(prev => + prev.includes(model) + ? prev.filter(m => m !== model) + : [...prev, model] + ) + } + + const allModels = Array.from(new Set([...availableModels, ...allowedModels])) + + 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.' + )} +

+
+ +
+ {/* ── Section 1: API Connection ── */} +
+
+ 1 + + {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" + /> + + + + + {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('test_connection', 'Test Connection')} + + {testStatus === 'success' && ( + + + {t('connection_successful', 'Connection successful')} + + )} + {testStatus === 'error' && ( + + + {t('connection_failed', 'Connection failed — check URL and key')} + + )} +
+
+ + {/* ── Section 2: Model Selection ── */} +
+
+ 2 + + {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')} + +
+ + )} +
+ + {/* ── Section 3: System Prompt ── */} +
+
+ 3 + + {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')} + +
+
+ + {/* ── Notifications ── */} + {showSuccess && ( +
+ +
+ )} + {isError && ( +
+ +
+ )} + + {/* ── Save Button ── */} + + + {t('save_settings', 'Save Settings')} + +
+
+
+
+
+ ) +} diff --git a/services/web/modules/llm/frontend/js/components/llm-chat-pane.tsx b/services/web/modules/llm/frontend/js/components/llm-chat-pane.tsx new file mode 100644 index 0000000000..b07ab844cf --- /dev/null +++ b/services/web/modules/llm/frontend/js/components/llm-chat-pane.tsx @@ -0,0 +1,231 @@ +import React, { useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import MaterialIcon from '@/shared/components/material-icon' +import { useLLMChat } from '../hooks/use-llm-chat' +import { marked } from 'marked' +import '../../stylesheets/llm-chat.scss' + +interface LLMChatPaneProps { + isOpen?: boolean + setIsOpen?: (open: boolean) => void +} + +const LLMChatPane = React.memo(function LLMChatPane({ + isOpen, + setIsOpen, +}: LLMChatPaneProps) { + const { t } = useTranslation() + const inputRef = useRef(null) + const { + messages, + isLoading, + sendMessage, + stopGeneration, + rerunLastMessage, + clearMessages, + models, + selectedModel, + setSelectedModel, + canRerun, + modelsLoaded, + hasModels, + } = useLLMChat() + + const [inputValue, setInputValue] = useState('') + + // Close chat panel if models become unavailable (only when controlled externally) + useEffect(() => { + if (modelsLoaded && !hasModels && isOpen === true && setIsOpen) { + setIsOpen(false) + } + }, [modelsLoaded, hasModels, isOpen, setIsOpen]) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (inputValue.trim() && !isLoading) { + sendMessage(inputValue) + setInputValue('') + } + } + + const handleStop = () => { + stopGeneration() + } + + const handleRerun = () => { + rerunLastMessage() + } + + const handleClear = () => { + if (confirm(t('clear_conversation_confirm', 'Clear conversation?'))) { + clearMessages() + } + } + + if (modelsLoaded && !hasModels) { + return null + } + + if (isOpen === false) { + return null + } + + const displayMessages = messages.filter(m => m.role !== 'system') + const showModelSelector = models.length > 0 + + return ( +