LLM: Initial files

This commit is contained in:
David Rotermund
2026-08-03 02:49:45 +02:00
committed by yu-i-i
parent cf70bc751e
commit f628081f90
33 changed files with 5003 additions and 17 deletions
@@ -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()
})
+5
View File
@@ -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 }
)
@@ -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
+31 -13
View File
@@ -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: {},
@@ -25,6 +25,7 @@ export type RailTabKey =
| 'full-project-search'
| 'dimensions'
| 'workbench'
| 'llm-chat'
export type RailModalKey =
| 'keyboard-shortcuts'
@@ -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(
<ReviewTooltipMenuContent onAddComment={addComment} />,
<ReviewTooltipMenuContent onAddComment={addComment} onHide={() => 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 }>(
<MaterialIcon type="chat" />
{t('add_comment')}
</button>
{llmEnabled && (
<>
<div className="review-tooltip-menu-divider" />
<button
className="review-tooltip-menu-button"
onClick={() => {
const selectedText = view.state.sliceDoc(
state.selection.main.from,
state.selection.main.to
)
document.dispatchEvent(
new CustomEvent('llm-ask-ai-selection', {
detail: { text: selectedText },
})
)
onHide()
}}
>
<MaterialIcon type="smart_toy" />
{t('ask_ai', 'Ask AI')}
</button>
</>
)}
{showChangesButtons && (
<>
<div className="review-tooltip-menu-divider" />
@@ -32,6 +32,11 @@ export function AccountMenuItems({
<NavDropdownLinkItem href="/user/settings">
{t('account_settings')}
</NavDropdownLinkItem>
{getMeta('ol-ExposedSettings')?.llmAllowUserSettings ? (
<NavDropdownLinkItem href="/user/llm-settings">
{t('ai_settings', 'AI Settings')}
</NavDropdownLinkItem>
) : null}
{showSubscriptionLink ? (
<NavDropdownLinkItem href="/user/subscription">
{t('subscription')}
@@ -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
</NavDropdownLinkItem>
) : null}
{(getMeta('ol-ExposedSettings') as any)?.llmEnabled ? (
<NavDropdownLinkItem href="/admin/llm/settings">
LLM Settings
</NavDropdownLinkItem>
) : null}
</NavDropdownMenu>
)
}
+3
View File
@@ -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</0> 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 <b>__institutionName__</b>, you must log in to <b>__appName__</b> 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__</0>. Ask the owner of the project (<1>__projectOwnerEmail__</1>) to add you as a collaborator to this repository.",
"ask_proj_owner_to_export_to_github": "Ask the owner of the project (<0>__projectOwnerEmail__</0>) 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__</0>) to unlink the project from the current git repository and create a connection to a different repository.",
@@ -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),
}
@@ -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 <think> tags (for DeepSeek, Qwen and similar models)
// Handles both closed <think>...</think> and unclosed <think>... at end of string
function stripThinkTags(content) {
let cleaned = content.replace(/<think>[\s\S]*?<\/think>\s*/gi, '')
cleaned = cleaned.replace(/<think>[\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 <think> 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 <think> 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),
}
@@ -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')
},
}
@@ -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),
}
@@ -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
@@ -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
@@ -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<string>(
(getMeta('ol-systemPrompt') as string) || DEFAULT_SYSTEM_PROMPT
)
const [llmApiUrl, setLlmApiUrl] = useState<string>(
(getMeta('ol-llmApiUrl') as string) || ''
)
const [llmApiKey, setLlmApiKey] = useState<string>('')
const [allowedModels, setAllowedModels] = useState<string[]>(
((getMeta('ol-allowedModels') as string) || '')
.split(',')
.map(m => m.trim())
.filter(Boolean)
)
const [availableModels, setAvailableModels] = useState<string[]>([])
const [scanStatus, setScanStatus] = useState<string | null>(null)
const [testStatus, setTestStatus] = useState<string | null>(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 (
<div className="container" style={{ maxWidth: '800px', margin: '0 auto' }}>
<OLRow>
<OLCol>
<div style={{ padding: '2rem 0' }}>
{/* Page header */}
<div style={{ marginBottom: '2rem' }}>
<h1 style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.5rem' }}>
<MaterialIcon type="smart_toy" />
{t('llm_configuration', 'LLM Configuration')}
</h1>
<p style={{ color: 'var(--content-secondary, #6c757d)', margin: 0 }}>
{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.'
)}
</p>
</div>
<form onSubmit={handleSave}>
{/* ── Section 1: API Connection ── */}
<div style={sectionStyle}>
<div style={sectionHeaderStyle}>
<span style={stepNumberStyle}>1</span>
<MaterialIcon type="link" />
{t('api_connection', 'API Connection')}
{testStatus === 'success' && (
<OLBadge bg="success" style={{ marginLeft: 'auto', fontSize: '0.75rem' }}>
{t('connected', 'Connected')}
</OLBadge>
)}
</div>
<p style={sectionDescStyle}>
{t(
'api_connection_desc',
'Enter the endpoint URL and API key for your OpenAI-compatible LLM provider.'
)}
</p>
<OLFormGroup controlId="llm-api-url">
<OLFormLabel>
{t('llm_api_url', 'API Endpoint URL')}
</OLFormLabel>
<OLFormControl
type="url"
value={llmApiUrl}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setLlmApiUrl(e.target.value)
}
placeholder="https://api.example.com/v1"
/>
</OLFormGroup>
<OLFormGroup controlId="llm-api-key" style={{ marginBottom: '1rem' }}>
<OLFormLabel>
{t('llm_api_key', 'API Key')}
</OLFormLabel>
<OLFormControl
type="password"
value={llmApiKey}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
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 && (
<OLFormText>
<MaterialIcon type="check_circle" className="me-1" style={{ fontSize: '0.875rem', color: 'var(--green-60, #198754)' }} />
{t('llm_api_key_stored', 'An API key is already stored. Leave blank to keep it.')}
</OLFormText>
)}
</OLFormGroup>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
<OLButton
variant="secondary"
size="sm"
type="button"
onClick={testConnection}
disabled={!canConnect}
isLoading={testStatus === 'testing'}
>
<MaterialIcon type="cable" className="me-1" style={{ fontSize: '1rem' }} />
{t('test_connection', 'Test Connection')}
</OLButton>
{testStatus === 'success' && (
<span style={statusBadgeStyle('success')}>
<MaterialIcon type="check_circle" style={{ fontSize: '1rem' }} />
{t('connection_successful', 'Connection successful')}
</span>
)}
{testStatus === 'error' && (
<span style={statusBadgeStyle('error')}>
<MaterialIcon type="error" style={{ fontSize: '1rem' }} />
{t('connection_failed', 'Connection failed — check URL and key')}
</span>
)}
</div>
</div>
{/* ── Section 2: Model Selection ── */}
<div style={sectionStyle}>
<div style={sectionHeaderStyle}>
<span style={stepNumberStyle}>2</span>
<MaterialIcon type="model_training" />
{t('model_selection', 'Model Selection')}
{allModels.length > 0 && (
<span style={{ marginLeft: 'auto', fontSize: '0.8125rem', color: 'var(--content-secondary, #6c757d)' }}>
{allowedModels.filter(m => allModels.includes(m)).length}/{allModels.length} {t('selected', 'selected')}
</span>
)}
</div>
<p style={sectionDescStyle}>
{t(
'model_selection_desc',
'Scan the API for available models, then choose which ones users can access.'
)}
</p>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: allModels.length > 0 ? '1rem' : 0 }}>
<OLButton
variant="secondary"
size="sm"
type="button"
onClick={scanModels}
disabled={!canConnect}
isLoading={scanStatus === 'scanning'}
>
<MaterialIcon type="radar" className="me-1" style={{ fontSize: '1rem' }} />
{t('scan_for_models', 'Scan for Models')}
</OLButton>
{scanStatus === 'success' && (
<span style={statusBadgeStyle('success')}>
<MaterialIcon type="check_circle" style={{ fontSize: '1rem' }} />
{t('scan_found_models', `Found ${availableModels.length} model(s)`)}
</span>
)}
{scanStatus === 'error' && (
<span style={statusBadgeStyle('error')}>
<MaterialIcon type="error" style={{ fontSize: '1rem' }} />
{t('scan_failed', 'Scan failed — check connection first')}
</span>
)}
{!canConnect && scanStatus === null && (
<span style={{ fontSize: '0.8125rem', color: 'var(--content-secondary, #6c757d)' }}>
{t('configure_api_first', 'Configure the API connection above first')}
</span>
)}
</div>
{allModels.length > 0 && (
<>
<div style={{
border: '1px solid var(--border-color-01, #dee2e6)',
borderRadius: '6px',
overflow: 'hidden',
}}>
{allModels.map((model, idx) => (
<label
key={model}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
padding: '0.625rem 1rem',
borderBottom: idx < allModels.length - 1
? '1px solid var(--border-color-01, #dee2e6)'
: undefined,
cursor: 'pointer',
margin: 0,
transition: 'background-color 0.15s',
}}
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--bg-light-secondary, #f8f9fa)' }}
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.backgroundColor = '' }}
>
<input
type="checkbox"
checked={allowedModels.includes(model)}
onChange={() => toggleAllowedModel(model)}
style={{ width: '1rem', height: '1rem', accentColor: 'var(--bg-accent-01, #0d6efd)' }}
/>
<span style={{ fontFamily: 'monospace', fontSize: '0.875rem' }}>
{model}
</span>
</label>
))}
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.75rem' }}>
<OLButton
variant="link"
size="sm"
type="button"
onClick={() => setAllowedModels([...allModels])}
style={{ padding: 0, fontSize: '0.8125rem' }}
>
{t('select_all', 'Select all')}
</OLButton>
<span style={{ color: 'var(--content-secondary, #6c757d)' }}>|</span>
<OLButton
variant="link"
size="sm"
type="button"
onClick={() => setAllowedModels([])}
style={{ padding: 0, fontSize: '0.8125rem' }}
>
{t('deselect_all', 'Deselect all')}
</OLButton>
</div>
</>
)}
</div>
{/* ── Section 3: System Prompt ── */}
<div style={sectionStyle}>
<div style={sectionHeaderStyle}>
<span style={stepNumberStyle}>3</span>
<MaterialIcon type="description" />
{t('system_prompt', 'System Prompt')}
</div>
<p style={sectionDescStyle}>
{t(
'system_prompt_desc',
'This prompt is prepended to every AI conversation. Use it to customize the assistant\'s behavior for your organization.'
)}
</p>
<OLFormGroup controlId="llm-system-prompt" style={{ marginBottom: '0.5rem' }}>
<OLFormControl
as="textarea"
rows={12}
value={systemPrompt}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
setSystemPrompt(e.target.value)
}
placeholder={t(
'llm_system_prompt_placeholder',
'You are a helpful LaTeX assistant...'
)}
maxLength={4000}
style={{ fontFamily: 'monospace', fontSize: '0.8125rem' }}
/>
</OLFormGroup>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<OLFormText style={{ margin: 0 }}>
{systemPrompt.length}/4000 {t('characters', 'characters')}
</OLFormText>
<OLButton
variant="link"
size="sm"
type="button"
onClick={() => setSystemPrompt(DEFAULT_SYSTEM_PROMPT)}
style={{ padding: 0, fontSize: '0.8125rem' }}
>
<MaterialIcon type="restart_alt" className="me-1" style={{ fontSize: '1rem' }} />
{t('reset_to_default', 'Reset to default')}
</OLButton>
</div>
</div>
{/* ── Notifications ── */}
{showSuccess && (
<div style={{ marginBottom: '1rem' }}>
<OLNotification
type="success"
content={t('llm_settings_saved', 'LLM settings saved successfully.')}
/>
</div>
)}
{isError && (
<div style={{ marginBottom: '1rem' }}>
<OLNotification
type="error"
content={
(error as any)?.message ??
t('generic_something_went_wrong', 'Something went wrong')
}
/>
</div>
)}
{/* ── Save Button ── */}
<OLButton
variant="primary"
type="submit"
disabled={isSaving}
isLoading={isSaving}
loadingLabel={t('saving') + '…'}
style={{ minWidth: '160px' }}
>
<MaterialIcon type="save" className="me-1" style={{ fontSize: '1.125rem' }} />
{t('save_settings', 'Save Settings')}
</OLButton>
</form>
</div>
</OLCol>
</OLRow>
</div>
)
}
@@ -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<HTMLTextAreaElement>(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 (
<aside className="chat" aria-label={t('ai_assistant', 'AI Assistant')}>
<div className="llm-chat-container">
{/* Header with Model Selector and Action Buttons */}
<div className="llm-chat-header">
{showModelSelector && (
<div className="llm-model-selector">
<label htmlFor="model-select">
{t('model_label', 'Model')}
</label>
<select
id="model-select"
value={selectedModel}
onChange={e => setSelectedModel(e.target.value)}
disabled={isLoading}
aria-label={t('model_label', 'Model')}
>
{models.map(model => (
<option key={model.id} value={model.id}>
{model.name}
</option>
))}
</select>
</div>
)}
<div className="llm-action-buttons">
{canRerun && !isLoading && (
<button
type="button"
onClick={handleRerun}
className="llm-action-button"
title={t('re_run_last_question', 'Re-run last question')}
aria-label={t('re_run_last_question', 'Re-run last question')}
>
<MaterialIcon type="refresh" />
</button>
)}
{displayMessages.length > 0 && !isLoading && (
<button
type="button"
onClick={handleClear}
className="llm-action-button"
title={t('clear_conversation', 'Clear conversation')}
aria-label={t('clear_conversation', 'Clear conversation')}
>
<MaterialIcon type="delete" />
</button>
)}
</div>
</div>
{displayMessages.length === 0 ? (
<div className="llm-chat-welcome">
<MaterialIcon type="smart_toy" className="llm-welcome-icon" />
<h3>{t('latex_ai_assistant', 'LaTeX AI Assistant')}</h3>
<p>
{t(
'ai_assistant_description',
'Ask questions about LaTeX, get help with errors, and more.'
)}
</p>
<div className="llm-suggestions">
<p className="llm-suggestions-title">
{t('try_asking', 'Try asking')}:
</p>
<ul>
<li>"How do I create a table?"</li>
<li>"Help me fix this equation"</li>
<li>"How to add bibliography?"</li>
<li>"Explain this LaTeX command"</li>
</ul>
</div>
</div>
) : (
<div className="llm-chat-messages">
{displayMessages.map((msg, idx) => (
<div
key={idx}
className={`llm-message llm-message-${msg.role}`}
>
<div className="message-container">
<div
className="message-content"
// Content comes only from the configured LLM API — not from other users
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{
__html: marked.parse(msg.content) as string,
}}
/>
</div>
</div>
))}
{isLoading && (
<div className="llm-message llm-message-assistant">
<div className="message-container">
<div className="llm-message-loading">
<MaterialIcon
type="smart_toy"
className="loading-icon"
/>
<span>{t('thinking', 'Thinking...')}</span>
<button
type="button"
onClick={handleStop}
className="llm-stop-button"
title={t('stop_generation', 'Stop')}
aria-label={t('stop_generation', 'Stop')}
>
<MaterialIcon type="stop" />
</button>
</div>
</div>
</div>
)}
</div>
)}
{/* AI assistant input */}
<form className="llm-chat-input-form" onSubmit={handleSubmit}>
<textarea
ref={inputRef}
id="llm-chat-input"
className="llm-chat-textarea"
value={inputValue}
onChange={e => setInputValue(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSubmit(e as any)
}
}}
placeholder={t(
'ask_about_latex_placeholder',
'Ask about LaTeX...'
)}
disabled={isLoading}
rows={1}
aria-label={t('ai_assistant_input', 'AI assistant input')}
/>
<button
type="submit"
className="llm-chat-send-button"
disabled={isLoading || !inputValue.trim()}
aria-label={t('send', 'Send')}
>
<MaterialIcon type="send" />
</button>
</form>
</div>
</aside>
)
})
export default LLMChatPane
@@ -0,0 +1,49 @@
import React, { useState } from 'react'
import classNames from 'classnames'
import { useTranslation } from 'react-i18next'
import MaterialIcon from '@/shared/components/material-icon'
import { useLLMChat } from '../hooks/use-llm-chat'
import LLMChatPane from './llm-chat-pane'
function LLMChatToggleButton() {
const { t } = useTranslation()
const { modelsLoaded, modelsError, hasModels } = useLLMChat()
const [isOpen, setIsOpen] = useState(false)
// Hide button only when models have been successfully loaded but none are available
// (explicit server-side "LLM not configured"). Keep visible on errors.
if (modelsLoaded && !modelsError && !hasModels) {
return null
}
return (
<>
<button
type="button"
className={classNames('ol-cm-toolbar-button', { active: isOpen })}
onClick={() => setIsOpen(prev => !prev)}
title={t('ai_assistant', 'AI Assistant')}
aria-label={t('ai_assistant', 'AI Assistant')}
>
<MaterialIcon type="smart_toy" />
</button>
{isOpen && (
<div
style={{
position: 'fixed',
top: 0,
right: 0,
bottom: 0,
width: 420,
zIndex: 100,
boxShadow: '-2px 0 12px rgba(0,0,0,0.15)',
}}
>
<LLMChatPane isOpen={isOpen} onClose={() => setIsOpen(false)} />
</div>
)}
</>
)
}
export default LLMChatToggleButton
@@ -0,0 +1,666 @@
// LLM Inline Completion for CodeMirror
// Adapted from lcpu-club/overleaf — provides ghost text inline suggestions
// Uses the module's /project/:id/llm/completion endpoint
import {
Extension,
StateEffect,
StateField,
EditorState,
Prec,
} from '@codemirror/state'
import {
EditorView,
ViewPlugin,
ViewUpdate,
Decoration,
DecorationSet,
WidgetType,
keymap,
} from '@codemirror/view'
import getMeta from '@/utils/meta'
// =================================================================================
// Types
// =================================================================================
export interface CompletionOptions {
cursorOffset: number
leftContext: string
rightContext: string
language: string
maxLength: number
fileList: string[]
outline: string[]
}
type Suggestion = {
from: number
text: string
preview: string
}
type LlmCompletionResult =
| { kind: 'ok'; data: string }
| { kind: 'aborted' }
| { kind: 'error'; reason?: string; body?: any }
// =================================================================================
// Backend Service
// =================================================================================
class LlmCompletionService {
async createCompletion(
options: CompletionOptions,
signal?: AbortSignal
): Promise<LlmCompletionResult> {
try {
const projectId = getMeta('ol-project_id')
const csrfToken = getMeta('ol-csrfToken')
const res = await fetch(`/project/${projectId}/llm/completion`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
},
credentials: 'same-origin',
body: JSON.stringify(options),
signal,
})
let parsed: any
try {
parsed = await res.json()
} catch {
return { kind: 'error', reason: 'invalid-json' }
}
if (!res.ok) {
return { kind: 'error', reason: 'http-error', body: parsed }
}
if (
parsed &&
typeof parsed === 'object' &&
typeof parsed.success === 'boolean'
) {
if (parsed.success && typeof parsed.data === 'string') {
return { kind: 'ok', data: parsed.data }
} else {
return { kind: 'error', reason: 'backend-failure', body: parsed }
}
}
return { kind: 'error', reason: 'unexpected-format', body: parsed }
} catch (err: any) {
if (err.name === 'AbortError') {
return { kind: 'aborted' }
}
return { kind: 'error', reason: 'network-or-unknown' }
}
}
}
const llmCompletion = new LlmCompletionService()
// =================================================================================
// State Management
// =================================================================================
const setSuggestionEffect = StateEffect.define<Suggestion | null>()
const suggestionField = StateField.define<Suggestion | null>({
create: () => null,
update(value, tr) {
for (const e of tr.effects) {
if (e.is(setSuggestionEffect)) return e.value
}
if (!value) return value
if (tr.docChanged) {
let changeCount = 0
let isSimpleInsertion = true
let insertedText = ''
let changeFrom = -1
tr.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => {
changeCount++
if (fromA !== toA || inserted.toString().includes('\n')) {
isSimpleInsertion = false
}
insertedText += inserted.toString()
if (changeFrom === -1) changeFrom = fromA
})
const shouldUpdate =
isSimpleInsertion &&
changeCount === 1 &&
changeFrom === value.from &&
insertedText.length > 0 &&
value.text.startsWith(insertedText)
if (shouldUpdate) {
const newText = value.text.slice(insertedText.length)
if (newText.length > 0) {
const newFrom = value.from + insertedText.length
return {
from: newFrom,
text: newText,
preview: createPreview(newText),
}
}
}
return null
}
if (
tr.selection &&
(!tr.selection.main.empty || tr.selection.main.from !== value.from)
) {
return null
}
return value
},
provide: f => [
EditorView.decorations.compute([f], state => {
const suggestion = state.field(f)
return suggestion
? renderGhostDecorations(suggestion, state)
: Decoration.none
}),
],
})
// =================================================================================
// UI Widgets & Decorations
// =================================================================================
class GhostTextWidget extends WidgetType {
private static readonly MAX_PREVIEW_LINES = 3
constructor(readonly text: string) {
super()
}
eq(other: GhostTextWidget) {
return this.text === other.text
}
toDOM() {
const span = document.createElement('span')
span.className = 'cm-ghostText'
let text = this.text
const lines = text.split('\n')
if (lines.length > GhostTextWidget.MAX_PREVIEW_LINES) {
text =
lines.slice(0, GhostTextWidget.MAX_PREVIEW_LINES).join('\n') + '\n...'
}
span.textContent = text
return span
}
}
function renderGhostDecorations(
s: Suggestion,
_state: EditorState
): DecorationSet {
return Decoration.set([
Decoration.widget({
widget: new GhostTextWidget(s.text),
side: 1,
}).range(s.from),
])
}
// =================================================================================
// Core Plugin Logic
// =================================================================================
class InlineCompletionPlugin {
private view: EditorView
private config = {
language: 'latex',
debounceMs: 1000,
maxLeftLines: 10,
maxRightLines: 2,
maxLength: 60,
}
private debounceTimer: number | null = null
private isActive = false
private currentSuggestionPos = 0
private requestAbortController: AbortController | null = null
private requestSeq = 0
private latestRequestId = 0
private pendingRequestSeed: string | null = null
private readonly seedLen = 50
private isComposing = false
private readonly compositionStartHandler: () => void
private readonly compositionEndHandler: () => void
constructor(view: EditorView) {
this.view = view
this.compositionStartHandler = () => {
this.isComposing = true
}
this.compositionEndHandler = () => {
this.isComposing = false
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(
() => {
if (!this.isComposing) {
this.debounceTrigger()
}
},
{ timeout: 500 }
)
} else {
setTimeout(() => {
if (!this.isComposing) {
this.debounceTrigger()
}
}, 200)
}
}
this.setupCompositionListeners()
}
update(update: ViewUpdate) {
if (this.isComposing || !update.state.selection.main.empty) {
this.cancel('composing or selection not empty')
return
}
if (update.selectionSet && this.isActive) {
const suggestion = this.view.state.field(suggestionField, false)
if (
suggestion &&
update.state.selection.main.from !== suggestion.from
) {
this.cancel('cursor moved')
}
}
if (shouldTriggerOnInsertion(update, 30)) {
this.debounceTrigger()
}
}
destroy() {
this.teardownCompositionListeners()
this.cancel('plugin destroyed')
}
private setupCompositionListeners() {
this.view.dom.addEventListener(
'compositionstart',
this.compositionStartHandler
)
this.view.dom.addEventListener(
'compositionend',
this.compositionEndHandler
)
}
private teardownCompositionListeners() {
this.view.dom.removeEventListener(
'compositionstart',
this.compositionStartHandler
)
this.view.dom.removeEventListener(
'compositionend',
this.compositionEndHandler
)
}
private debounceTrigger() {
if (this.debounceTimer) clearTimeout(this.debounceTimer)
this.debounceTimer = window.setTimeout(() => {
this.trigger().catch(err => {
console.error(
'[LLM-Completion] Unhandled error in trigger:',
err
)
})
}, this.config.debounceMs)
}
triggerManual() {
this.trigger().catch(err => {
console.error(
'[LLM-Completion] Unhandled error in manual trigger:',
err
)
})
}
private async trigger() {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer)
this.debounceTimer = null
}
this.cancel('new request')
if (this.isComposing) return
const pos = this.view.state.selection.main.head
const { leftContext, rightContext } = computeContexts(
this.view,
pos,
this.config.maxLeftLines,
this.config.maxRightLines
)
this.isActive = true
this.currentSuggestionPos = pos
this.requestAbortController = new AbortController()
const seedLines = 3
const { leftContext: seedLeft, rightContext: seedRight } =
computeContexts(this.view, pos, seedLines, seedLines)
const seed =
(seedLeft.slice(-this.seedLen) || '') +
'|' +
(seedRight.slice(0, this.seedLen) || '')
this.pendingRequestSeed = seed
const requestId = ++this.requestSeq
this.latestRequestId = requestId
const { filelist, outline } = this.collectProjectContext()
const language = this.getCurrentFileLanguage()
const result = await llmCompletion.createCompletion(
{
cursorOffset: pos,
leftContext,
rightContext,
language,
maxLength: this.config.maxLength,
fileList: filelist,
outline: outline,
},
this.requestAbortController.signal
)
if (result.kind === 'aborted' || requestId !== this.latestRequestId) {
return
}
if (result.kind === 'error') {
this.cancel('request error')
return
}
if (
!this.verifySeedAtPosition(
this.currentSuggestionPos,
this.pendingRequestSeed
)
) {
this.cancel('seed-mismatch')
return
}
const completionText = result.data
if (!completionText) {
this.cancel('no-completion')
return
}
if (
this.view.state.selection.main.head !== this.currentSuggestionPos
) {
this.cancel('cursor-moved-after-request')
return
}
this.view.dispatch({
effects: setSuggestionEffect.of({
from: this.currentSuggestionPos,
text: completionText,
preview: createPreview(completionText),
}),
})
}
accept(): boolean {
const suggestion = this.view.state.field(suggestionField, false)
if (!suggestion) return false
if (this.view.state.selection.main.head !== suggestion.from) {
this.cancel('accept-failed-cursor-moved')
return false
}
this.view.dispatch({
changes: { from: suggestion.from, insert: suggestion.text },
selection: { anchor: suggestion.from + suggestion.text.length },
effects: setSuggestionEffect.of(null),
})
this.isActive = false
return true
}
cancel(reason?: string) {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer)
this.debounceTimer = null
}
this.isActive = false
this.pendingRequestSeed = null
if (this.requestAbortController) {
this.requestAbortController.abort()
this.requestAbortController = null
}
if (this.view.state.field(suggestionField, false)) {
this.view.dispatch({ effects: setSuggestionEffect.of(null) })
}
}
private collectProjectContext() {
if (typeof document === 'undefined')
return { filelist: [], outline: [] }
const filelist: string[] = []
const outline: string[] = []
try {
document
.querySelectorAll('.file-tree .entity-name')
.forEach(el => {
const text = el.textContent?.trim()
if (text) filelist.push(text)
})
document
.querySelectorAll('.outline-pane .outline-item')
.forEach(el => {
const text = el.textContent?.trim()
if (text) outline.push(text)
})
} catch {
// ignore
}
return { filelist, outline }
}
private getCurrentFileLanguage(): string {
if (typeof document === 'undefined') return this.config.language
try {
const selectedItem = document.querySelector(
'.file-tree li.selected span'
)
const fileName = selectedItem?.textContent?.trim() || ''
const parts = fileName.split('.')
if (parts.length > 1) {
return parts.pop()!
}
} catch {
// ignore
}
return this.config.language
}
private verifySeedAtPosition(
pos: number,
seed: string | null
): boolean {
if (!seed) return false
const seedLines = 3
const { leftContext, rightContext } = computeContexts(
this.view,
pos,
seedLines,
seedLines
)
const currentSeed =
(leftContext.slice(-this.seedLen) || '') +
'|' +
(rightContext.slice(0, this.seedLen) || '')
return currentSeed === seed
}
}
// =================================================================================
// Helpers
// =================================================================================
function createPreview(s: string, maxLines: number = 3): string {
const lines = s.split('\n')
if (lines.length <= maxLines) {
return s
}
return lines.slice(0, maxLines).join('\n') + '\n...'
}
function computeContexts(
view: EditorView,
pos: number,
maxLeftLines: number,
maxRightLines: number
) {
const doc = view.state.doc
const currentLine = doc.lineAt(pos)
const leftStartLine = Math.max(1, currentLine.number - maxLeftLines)
const leftStartPos = doc.line(leftStartLine).from
const leftContext = doc.sliceString(leftStartPos, pos)
const rightEndLine = Math.min(
doc.lines,
currentLine.number + maxRightLines
)
const rightEndPos = doc.line(rightEndLine).to
const rightContext = doc.sliceString(pos, rightEndPos)
return { leftContext, rightContext }
}
function shouldTriggerOnInsertion(
update: ViewUpdate,
maxInsertThreshold: number
): boolean {
if (!update.docChanged) return false
let insertedTextLength = 0
let isSimpleInsertion = true
update.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => {
const ins = inserted.toString()
if (fromA !== toA || ins.includes('\n')) {
isSimpleInsertion = false
}
insertedTextLength += ins.length
})
return (
isSimpleInsertion &&
insertedTextLength > 0 &&
insertedTextLength <= maxInsertThreshold
)
}
// =================================================================================
// Export
// =================================================================================
export const INLINE_COMPLETION_PLUGIN = ViewPlugin.define(
(view: EditorView) => new InlineCompletionPlugin(view),
{
destroy: plugin => plugin.destroy(),
}
)
export function inlineCompletionExtension(): Extension {
return [
suggestionField,
INLINE_COMPLETION_PLUGIN,
Prec.highest(
keymap.of([
{
key: 'Tab',
run: view => {
const plugin = view.plugin(INLINE_COMPLETION_PLUGIN)
if (plugin) {
const accepted = plugin.accept()
if (accepted) return true
}
return false // fall through to default Tab behaviour
},
},
{
key: 'Mod-Enter',
run: view => {
const plugin = view.plugin(INLINE_COMPLETION_PLUGIN)
return plugin ? plugin.accept() : false
},
},
{
key: 'Escape',
run: view => {
const plugin = view.plugin(INLINE_COMPLETION_PLUGIN)
if (plugin) {
plugin.cancel('escape key')
return true
}
return false
},
},
{
key: 'Mod-\\',
run: view => {
const plugin = view.plugin(INLINE_COMPLETION_PLUGIN)
if (plugin) {
plugin.triggerManual()
return true
}
return false
},
},
])
),
EditorView.baseTheme({
'.cm-ghostText': {
opacity: '0.45',
color: 'var(--cm-ghost-foreground, #888)',
pointerEvents: 'none',
fontFamily: 'inherit',
fontSize: 'inherit',
lineHeight: 'inherit',
fontWeight: 'inherit',
whiteSpace: 'pre-wrap',
wordWrap: 'break-word',
maxWidth: '100%',
verticalAlign: 'baseline',
},
}),
]
}
@@ -0,0 +1,27 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import RailPanelHeader from '@/features/ide-react/components/rail/rail-panel-header'
import LLMChatPane from './llm-chat-pane'
import { RailElement } from '@/features/ide-react/util/rail-types'
import getMeta from '@/utils/meta'
function LLMRailPane() {
const { t } = useTranslation()
return (
<div className="llm-rail-panel">
<RailPanelHeader title={t('ai_assistant', 'AI Assistant')} />
<LLMChatPane />
</div>
)
}
const llmRailEntry: RailElement = {
key: 'llm-chat',
icon: 'smart_toy',
title: 'AI Assistant',
component: <LLMRailPane />,
hide: () => !(getMeta('ol-ExposedSettings') as any)?.llmEnabled,
}
export default llmRailEntry
@@ -0,0 +1,38 @@
import React from 'react'
import { useTranslation } from 'react-i18next'
import getMeta from '@/utils/meta'
import LLMSettingsSection from './llm-settings-section'
import OLRow from '@/shared/components/ol/ol-row'
import OLCol from '@/shared/components/ol/ol-col'
import OLPageContentCard from '@/shared/components/ol/ol-page-content-card'
import { UserProvider } from '@/shared/context/user-context'
import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
import useScrollToIdOnLoad from '@/shared/hooks/use-scroll-to-id-on-load'
export default function LLMSettingsPage() {
const { t } = useTranslation()
const { isReady } = useWaitForI18n()
useScrollToIdOnLoad()
const user = getMeta('ol-user') || {}
return (
<div className="container">
<OLRow>
<OLCol xl={{ span: 10, offset: 1 }}>
{isReady ? (
<UserProvider>
<OLPageContentCard>
<div className="page-header">
<h1>{t('llm_settings', 'LLM Settings')}</h1>
</div>
<div>
<LLMSettingsSection initialSettings={user.llmSettings} />
</div>
</OLPageContentCard>
</UserProvider>
) : null}
</OLCol>
</OLRow>
</div>
)
}
@@ -0,0 +1,243 @@
import React, { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
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 OLFormControl from '@/shared/components/ol/ol-form-control'
import OLFormText from '@/shared/components/ol/ol-form-text'
import OLNotification from '@/shared/components/ol/ol-notification'
type Props = {
initialSettings?: {
useOwnSettings?: boolean
modelName?: string
apiUrl?: string
hasApiKey?: boolean
}
}
export default function LLMSettingsSection({ initialSettings }: Props) {
const { t } = useTranslation()
const [useOwnLLMSettings, setUseOwnLLMSettings] = useState(
initialSettings?.useOwnSettings || false
)
const [llmApiKey, setLlmApiKey] = useState('')
const [llmModelName, setLlmModelName] = useState(
initialSettings?.modelName || ''
)
const [llmApiUrl, setLlmApiUrl] = useState(initialSettings?.apiUrl || '')
const [llmHasApiKey, setLlmHasApiKey] = useState(
initialSettings?.hasApiKey || false
)
const [isCheckingConnection, setIsCheckingConnection] = useState(false)
const [connectionCheckResult, setConnectionCheckResult] = useState<{
success: boolean
message: string
} | null>(null)
const {
isLoading: isLlmSaving,
isSuccess: isLlmSuccess,
isError: isLlmError,
error: llmError,
runAsync: runLlmAsync,
} = useAsync()
const [showSuccessNotif, setShowSuccessNotif] = useState(false)
useEffect(() => {
if (isLlmSuccess) {
setShowSuccessNotif(true)
const timer = setTimeout(() => setShowSuccessNotif(false), 4000)
return () => clearTimeout(timer)
}
}, [isLlmSuccess])
const handleCheckLLMConnection = async () => {
setIsCheckingConnection(true)
setConnectionCheckResult(null)
try {
const response = await postJSON('/user/llm-settings/check', {
body: {
apiUrl: llmApiUrl,
apiKey: llmApiKey || undefined,
modelName: llmModelName,
},
})
setConnectionCheckResult({
success: true,
message: response.message || 'Connection successful',
})
} catch (err: any) {
setConnectionCheckResult({
success: false,
message: err.message || 'Connection failed',
})
} finally {
setIsCheckingConnection(false)
}
}
const handleSaveLLMSettings = () => {
runLlmAsync(
postJSON('/user/llm-settings', {
body: {
useOwnLLMSettings,
llmApiKey: llmApiKey || undefined,
llmModelName,
llmApiUrl,
},
})
)
.then(() => {
if (llmApiKey && llmApiKey.trim() !== '') {
setLlmHasApiKey(true)
setLlmApiKey('')
}
})
.catch(() => { })
}
const handleToggleUseOwnLLMSettings = (checked: boolean) => {
setUseOwnLLMSettings(checked)
if (!checked) {
setLlmApiKey('')
setLlmModelName('')
setLlmApiUrl('')
setConnectionCheckResult(null)
runLlmAsync(
postJSON('/user/llm-settings', {
body: {
useOwnLLMSettings: false,
llmApiKey: undefined,
llmModelName: '',
llmApiUrl: '',
},
})
).catch(() => { })
}
}
return (
<>
<OLFormGroup>
<div style={{ display: 'flex', alignItems: 'center' }}>
<input
type="checkbox"
id="use-own-llm-settings"
checked={useOwnLLMSettings}
onChange={e => handleToggleUseOwnLLMSettings(e.target.checked)}
style={{ marginRight: '0.5rem' }}
/>
<OLFormLabel htmlFor="use-own-llm-settings">
{t('use_my_own_llm_settings', 'Use my own LLM settings')}
</OLFormLabel>
</div>
</OLFormGroup>
{useOwnLLMSettings && (
<form
onSubmit={e => {
e.preventDefault()
handleSaveLLMSettings()
}}
>
<OLFormGroup controlId="llm-api-key-input">
<OLFormLabel>API Key</OLFormLabel>
<OLFormControl
type="password"
autoComplete="current-password"
value={llmApiKey}
onChange={e => setLlmApiKey(e.target.value)}
placeholder={llmHasApiKey ? '***' : 'Enter API Key'}
/>
{llmHasApiKey && !llmApiKey && (
<OLFormText>
Existing API key is set. Enter a new one to update.
</OLFormText>
)}
</OLFormGroup>
<OLFormGroup controlId="llm-model-name-input">
<OLFormLabel>Model Name</OLFormLabel>
<OLFormControl
type="text"
value={llmModelName}
onChange={e => setLlmModelName(e.target.value)}
placeholder="e.g., gpt-4, claude-3"
/>
</OLFormGroup>
<OLFormGroup controlId="llm-api-url-input">
<OLFormLabel>API URL</OLFormLabel>
<OLFormControl
type="text"
value={llmApiUrl}
onChange={e => setLlmApiUrl(e.target.value)}
placeholder="e.g., https://api.openai.com/v1"
/>
</OLFormGroup>
{connectionCheckResult && (
<OLFormGroup>
<OLNotification
type={connectionCheckResult.success ? 'success' : 'error'}
content={connectionCheckResult.message}
/>
</OLFormGroup>
)}
<OLFormGroup>
<OLButton
variant="secondary"
type="button"
onClick={handleCheckLLMConnection}
disabled={
isCheckingConnection ||
!llmApiUrl ||
(!llmApiKey && !llmHasApiKey) ||
!llmModelName
}
isLoading={isCheckingConnection}
loadingLabel="Checking..."
style={{ marginRight: '0.5rem' }}
>
Check Connection
</OLButton>
<OLButton
variant="primary"
type="submit"
disabled={isLlmSaving || !llmApiUrl || !llmModelName}
isLoading={isLlmSaving}
loadingLabel={t('saving') + '…'}
>
Save LLM Settings
</OLButton>
</OLFormGroup>
{showSuccessNotif && (
<OLFormGroup>
<OLNotification
type="success"
content="LLM settings saved successfully"
/>
</OLFormGroup>
)}
{isLlmError && (
<OLFormGroup>
<OLNotification
type="error"
content={
llmError?.message ?? 'Failed to save LLM settings'
}
/>
</OLFormGroup>
)}
</form>
)}
</>
)
}
@@ -0,0 +1,46 @@
// Extension point wrapper for sourceEditorComponents
// Provides root-level LLM editor features (inline completion, floating toolbar)
import React, { useEffect, useRef, useState } from 'react'
import { useCodeMirrorViewContext } from '@/features/source-editor/components/codemirror-context'
import { inlineCompletionExtension } from './llm-completion'
import LLMToolbar, { LLMToolbarHandle } from './llm-toolbar'
function LLMSourceEditorComponent() {
const view = useCodeMirrorViewContext()
const toolbarRef = useRef<LLMToolbarHandle>(null)
const [extensionInstalled, setExtensionInstalled] = useState(false)
// Install inline completion extension
useEffect(() => {
if (!view || extensionInstalled) return
try {
const ext = inlineCompletionExtension()
view.dispatch({
effects: (window as any).__cm_llm_reconfigure
? []
: // @ts-ignore
[],
})
setExtensionInstalled(true)
} catch {
// Extension may already be active or view not ready
}
}, [view, extensionInstalled])
// Listen for "Ask AI" from the review tooltip menu
useEffect(() => {
const handleAskAI = (e: Event) => {
const detail = (e as CustomEvent).detail
if (!detail?.text || !view || !toolbarRef.current) return
toolbarRef.current.show(view)
// Immediately open the menu panel
toolbarRef.current.openMenu?.()
}
document.addEventListener('llm-ask-ai-selection', handleAskAI)
return () => document.removeEventListener('llm-ask-ai-selection', handleAskAI)
}, [view])
return <LLMToolbar ref={toolbarRef} />
}
export default LLMSourceEditorComponent
@@ -0,0 +1,903 @@
// LLM Floating Toolbar for CodeMirror Editor
// Adapted from lcpu-club/overleaf — provides selection-based AI actions
// (paraphrase, style change, summarize, explain, etc.)
import React, {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import { EditorView } from '@codemirror/view'
import { marked } from 'marked'
import getMeta from '@/utils/meta'
export type LLMToolbarHandle = {
show: (view: EditorView) => void
hide: () => void
openMenu: () => void
getSelectedText: () => string
}
type ParaphraseKind =
| 'paraphrase'
| 'style'
| 'splitjoin'
| 'summarize'
| 'explain'
| 'title'
| 'abstract'
| 'chat'
const kindTitleMap: Record<ParaphraseKind, string> = {
paraphrase: 'Paraphrase',
style: 'Change Style',
splitjoin: 'Split / Join',
summarize: 'Summarize',
explain: 'Explain',
title: 'Title Generator',
abstract: 'Abstract Generator',
chat: 'AI Response',
}
function escapeHtml(s: string) {
return s.replace(
/[&<>"']/g,
c =>
({
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
}[c] || c)
)
}
function base64EncodeUnicode(str: string) {
try {
return btoa(unescape(encodeURIComponent(str)))
} catch {
return btoa(str)
}
}
function base64DecodeUnicode(b64: string) {
try {
return decodeURIComponent(escape(atob(b64)))
} catch {
return atob(b64)
}
}
const Spinner = () => (
<div
style={{
width: 16,
height: 16,
borderRadius: 8,
border: '2px solid rgba(255,255,255,0.12)',
borderTopColor: 'rgba(255,255,255,0.7)',
animation: 'llm-spin 0.9s linear infinite',
}}
/>
)
const LLMToolbar = forwardRef<LLMToolbarHandle, {}>((_, ref) => {
const [anchorShown, setAnchorShown] = useState(false)
const [panelRect, setPanelRect] = useState({ top: 0, left: 0, width: 520 })
const [anchorPos, setAnchorPos] = useState({ top: 0, left: 0 })
const [panelMode, setPanelMode] = useState<
'hidden' | 'menu' | 'chat' | 'paraphrase'
>('hidden')
const [submenu, setSubmenu] = useState<null | 'style' | 'splitjoin'>(null)
const [query, setQuery] = useState('')
const [selectionText, setSelectionText] = useState('')
const [selectionRange, setSelectionRange] = useState<{
from: number
to: number
} | null>(null)
const [result, setResult] = useState('')
const [loading, setLoading] = useState(false)
const [editMode, setEditMode] = useState(false)
const [showDiff, setShowDiff] = useState(false)
const [kind, setKind] = useState<ParaphraseKind>('paraphrase')
const viewRef = useRef<EditorView | null>(null)
const wrapRef = useRef<HTMLDivElement | null>(null)
const panelRef = useRef<HTMLDivElement | null>(null)
const inputRef = useRef<HTMLTextAreaElement | null>(null)
const editRef = useRef<HTMLTextAreaElement | null>(null)
const postToAPI = async (mode: number, ask: string) => {
const projectId = getMeta('ol-project_id')
const csrfToken = getMeta('ol-csrfToken')
// Build a prompt based on the mode
const modePrompts: Record<number, string> = {
0: ask, // free-form chat
1: `Paraphrase the following LaTeX text, keeping all LaTeX commands intact:\n\n${selectionText}`,
2: `Rewrite the following LaTeX text in a scientific academic style:\n\n${selectionText}`,
3: `Rewrite the following LaTeX text more concisely:\n\n${selectionText}`,
4: `Rewrite the following LaTeX text in a punchy style:\n\n${selectionText}`,
5: `Split the following LaTeX paragraph into multiple shorter paragraphs:\n\n${selectionText}`,
6: `Join the following LaTeX paragraphs into a single cohesive paragraph:\n\n${selectionText}`,
7: `Summarize the following LaTeX text:\n\n${selectionText}`,
8: `Explain the following LaTeX text:\n\n${selectionText}`,
9: `Generate an academic title based on this LaTeX content:\n\n${selectionText}`,
10: `Generate an academic abstract based on this LaTeX content:\n\n${selectionText}`,
}
const messages = [
{
role: 'system',
content:
'You are a helpful LaTeX writing assistant. Respond with clean LaTeX code when appropriate.',
},
{
role: 'user',
content: modePrompts[mode] || ask,
},
]
try {
const resp = await fetch(`/project/${projectId}/llm/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
},
credentials: 'same-origin',
body: JSON.stringify({ messages }),
})
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const json = await resp.json()
if (json?.choices?.[0]?.message?.content) {
return json.choices[0].message.content
}
throw new Error('Unexpected response format')
} catch (err: any) {
console.error('[LLM Toolbar] API error', err)
return `Error: ${err?.message || 'Request failed'}`
}
}
const startFetch = async (
mode: number,
k: ParaphraseKind,
ask?: string
) => {
setKind(k)
setPanelMode(k === 'chat' ? 'chat' : 'paraphrase')
setLoading(true)
setEditMode(false)
setShowDiff(false)
setResult('')
const resp = await postToAPI(mode, (ask ?? query).trim())
setResult(resp)
setLoading(false)
}
const renderer = useMemo(() => {
const r = new marked.Renderer()
r.code = (
code: string,
infostring: string | undefined,
_escaped: boolean
) => {
const lang = (infostring || '').trim().toLowerCase()
const isLatex =
lang === 'latex' ||
lang === 'tex' ||
code.trim().startsWith('\\')
const safeCodeHtml = escapeHtml(code)
if (isLatex) {
const b64 = base64EncodeUnicode(code)
return `<div class="llm-latex-block" style="position:relative;"><button class="llm-copy-latex" data-code="${b64}" title="Copy LaTeX" style="position:absolute;right:8px;top:8px;border-radius:6px;padding:4px 6px;border:none;background:rgba(255,255,255,0.03);color:#e6eef8;cursor:pointer">📋</button><pre style="margin:0;"><code class="language-${escapeHtml(lang)}">${safeCodeHtml}</code></pre></div>`
}
return `<pre><code class="language-${escapeHtml(lang)}">${safeCodeHtml}</code></pre>`
}
return r
}, [])
const renderedHtml = useMemo(() => {
try {
return marked.parse(result || 'No result yet.', { renderer })
} catch {
return escapeHtml(result || 'No result yet.')
}
}, [result, renderer])
// Copy button delegation
useEffect(() => {
const root = wrapRef.current
if (!root) return
const handler = (e: MouseEvent) => {
const target = e.target as HTMLElement
const btn = target.closest
? (target.closest('.llm-copy-latex') as HTMLElement | null)
: null
if (!btn) return
const b64 = btn.getAttribute('data-code')
if (!b64) return
const code = base64DecodeUnicode(b64)
; (async () => {
try {
await navigator.clipboard.writeText(code)
const prev = btn.innerText
btn.innerText = '✓'
setTimeout(() => {
btn.innerText = prev
}, 900)
} catch {
btn.innerText = '✕'
setTimeout(() => {
btn.innerText = '📋'
}, 900)
}
})()
}
root.addEventListener('click', handler)
return () => root.removeEventListener('click', handler)
}, [])
useImperativeHandle(ref, () => ({
show: (view: EditorView) => {
viewRef.current = view
const sel = view.state.selection.main
if (sel.empty) return
const wrapRect = wrapRef.current?.getBoundingClientRect()
const editorRect = view.dom.getBoundingClientRect()
const coordsFrom = view.coordsAtPos(sel.from)
const coordsTo = view.coordsAtPos(sel.to)
const toLocal = (c: { left: number; top: number }) => ({
x: wrapRect ? c.left - wrapRect.left : c.left,
y: wrapRect ? c.top - wrapRect.top : c.top,
})
let aTop = 0,
aLeft = 0
if (coordsFrom && coordsTo) {
const top = Math.min(coordsFrom.top, coordsTo.top)
const bottom = Math.max(coordsFrom.bottom, coordsTo.bottom)
const right = Math.max(coordsFrom.right, coordsTo.right)
const midY = top + (bottom - top) / 2
aTop = toLocal({ left: 0, top: midY }).y - 14
aLeft = toLocal({ left: right, top: 0 }).x - 14
} else {
aTop =
editorRect.top +
editorRect.height / 2 -
(wrapRect?.top ?? 0) -
14
aLeft = editorRect.right - (wrapRect?.left ?? 0) - 44
}
const containerW = wrapRect?.width ?? window.innerWidth
const containerH = wrapRect?.height ?? window.innerHeight
const clampedTop = Math.round(
Math.max(8, Math.min(containerH - 44, aTop))
)
const clampedLeft = Math.round(
Math.max(8, Math.min(containerW - 44, aLeft))
)
const width = Math.max(320, Math.min(560, containerW - 24))
const panelTop = Math.round(
Math.max(
12,
Math.min(
(wrapRect ? editorRect.top - wrapRect.top : 0) + 52,
containerH - 260 - 12
)
)
)
const panelLeft = Math.round(
Math.max(
12,
Math.min(
containerW - width - 12,
(wrapRect ? editorRect.left - wrapRect.left : 0) +
editorRect.width / 2 -
width / 2
)
)
)
setPanelRect({ top: panelTop, left: panelLeft, width })
setAnchorPos({ top: clampedTop, left: clampedLeft })
setSelectionText(view.state.sliceDoc(sel.from, sel.to))
setSelectionRange({ from: sel.from, to: sel.to })
setAnchorShown(true)
setPanelMode('hidden')
document.dispatchEvent(
new CustomEvent('llm-toolbar-active', { detail: { active: true } })
)
},
hide: () => {
setAnchorShown(false)
setPanelMode('hidden')
setSubmenu(null)
document.dispatchEvent(
new CustomEvent('llm-toolbar-active', { detail: { active: false } })
)
},
openMenu: () => {
setAnchorShown(true)
setPanelMode('menu')
},
getSelectedText: () => selectionText,
}))
useEffect(() => {
if (panelMode !== 'hidden') {
requestAnimationFrame(() =>
inputRef.current?.focus({ preventScroll: true } as any)
)
}
}, [panelMode])
const onInputKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
startFetch(0, 'chat')
} else if (e.key === 'Escape') {
setPanelMode('hidden')
setAnchorShown(false)
}
}
const replaceSelectionWith = (text: string) => {
const view = viewRef.current
const r = selectionRange
if (!view || !r) return
view.dispatch({ changes: { from: r.from, to: r.to, insert: text } })
setPanelMode('hidden')
setAnchorShown(false)
setResult('')
setQuery('')
}
const insertCodeAfterSelection = (text: string) => {
const view = viewRef.current
const r = selectionRange
if (!view || !r) return
// Extract LaTeX from markdown
const extractLatex = (md: string) => {
if (!md) return ''
let m: RegExpExecArray | null
let collected = ''
const fencedLangRegex = /```\s*(?:latex|tex)\n([\s\S]*?)```/gi
while ((m = fencedLangRegex.exec(md)) !== null)
collected += m[1].trim() + '\n'
if (collected) return collected.trim()
const fencedAny = /```(?:\w+)?\n([\s\S]*?)```/g
while ((m = fencedAny.exec(md)) !== null) {
const code = m[1]
if (/\\begin\{|\\[a-zA-Z]+|\\frac\{|\\end\{/.test(code))
collected += code.trim() + '\n'
}
if (collected) return collected.trim()
return md.trim()
}
const code = extractLatex(text)
const toInsert = code ? '\n' + code + '\n' : '\n' + text + '\n'
view.dispatch({ changes: { from: r.to, to: r.to, insert: toInsert } })
setPanelMode('hidden')
setAnchorShown(false)
setQuery('')
}
const copyToClipboard = async (t: string) => {
try {
await navigator.clipboard.writeText(t)
} catch {
// ignore
}
}
const kindToMode: Record<ParaphraseKind, number> = {
paraphrase: 1,
style: 2,
splitjoin: 5,
summarize: 7,
explain: 8,
title: 9,
abstract: 10,
chat: 0,
}
const contentMaxHeight = Math.max(
140,
Math.round(window.innerHeight * 0.5)
)
return (
<div
ref={wrapRef}
style={{
position: 'absolute',
inset: 0,
pointerEvents: 'none',
zIndex: 9999,
}}
>
<style>{`@keyframes llm-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}
.llm-anchor{position:absolute;width:28px;height:28px;border-radius:999px;background:linear-gradient(180deg,#0b1220,#0f172a);color:#eef2ff;border:1px solid rgba(255,255,255,0.08);display:inline-flex;align-items:center;justify-content:center;box-shadow:0 6px 22px rgba(2,6,23,0.45);cursor:pointer;pointer-events:auto}
.llm-panel{position:absolute;pointer-events:auto;user-select:none}
.llm-input-card{width:100%;background:linear-gradient(180deg,#071021,#071827);border-radius:14px;padding:12px 12px 12px 56px;position:relative;box-shadow:0 14px 40px rgba(3,8,22,0.55);border:1px solid rgba(255,255,255,0.04)}
.llm-badge{position:absolute;left:14px;top:50%;transform:translateY(-50%);width:30px;height:30px;border-radius:8px;background:rgba(255,255,255,0.03);display:flex;align-items:center;justify-content:center;color:#e6eef8}
.llm-input{width:100%;min-height:40px;max-height:160px;padding:8px 84px 8px 10px;border-radius:10px;background:transparent;color:#e6eef8;border:1px solid rgba(255,255,255,0.06);outline:none;resize:none;line-height:20px;font-size:14px;box-sizing:border-box}
.llm-send{position:absolute;right:44px;top:50%;transform:translateY(-50%);width:34px;height:34px;border-radius:9px;background:rgba(255,255,255,0.02);display:flex;align-items:center;justify-content:center;cursor:pointer}
.llm-close{position:absolute;right:8px;top:50%;transform:translateY(-50%);width:34px;height:34px;border-radius:9px;background:rgba(255,255,255,0.02);display:flex;align-items:center;justify-content:center;cursor:pointer}
.llm-menu{margin-top:0;background:linear-gradient(180deg,#071021,#071827);border-radius:12px;padding:10px 8px;color:#dfe7ee;border:1px solid rgba(255,255,255,0.04);box-shadow:0 14px 40px rgba(3,8,22,0.55)}
.llm-item{height:40px;display:flex;align-items:center;gap:10px;padding:0 10px;border-radius:10px;cursor:pointer}
.llm-item:hover{background:rgba(255,255,255,0.04)}
.llm-paraphrase-card{pointer-events:auto;width:100%;background:#071223;border-radius:12px;padding:12px;box-shadow:0 14px 40px rgba(3,8,22,0.55);border:1px solid rgba(255,255,255,0.06);color:#e6eef8}
.llm-paraphrase-footer{display:flex;justify-content:flex-end;gap:10px;margin-top:10px}
.llm-btn{padding:8px 12px;border-radius:10px;cursor:pointer;border:1px solid rgba(255,255,255,0.06);background:linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01));color:#e6eef8}
.llm-btn.llm-primary{background:linear-gradient(180deg, rgba(16,185,129,0.14), rgba(16,185,129,0.08));border:1px solid rgba(16,185,129,0.22)}
.llm-result-html{overflow-x:hidden;word-wrap:break-word;word-break:break-word;overflow-wrap:anywhere}
.llm-result-html h1,.llm-result-html h2,.llm-result-html h3{color:#e6eef8;margin:6px 0}
.llm-result-html p,.llm-result-html li{color:#dfe7ee;margin:4px 0}
.llm-result-html pre{background:rgba(0,0,0,0.45);padding:8px;border-radius:6px;overflow-x:auto;color:#e6eef8;white-space:pre-wrap;word-wrap:break-word;max-width:100%}
.llm-result-html code{word-wrap:break-word;white-space:pre-wrap}
`}</style>
{/* Circular AI anchor button - visible after selection */}
{anchorShown && panelMode === 'hidden' && !loading && (
<button
className="llm-anchor"
style={{ top: anchorPos.top, left: anchorPos.left }}
onClick={() => setPanelMode('menu')}
title="AI"
>
AI
</button>
)}
{/* Menu panel */}
{anchorShown && panelMode === 'menu' && (
<div
ref={panelRef}
className="llm-panel"
style={{
top: panelRect.top,
left: panelRect.left,
width: panelRect.width,
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div className="llm-input-card">
<div className="llm-badge">AI</div>
<textarea
ref={inputRef}
className="llm-input"
placeholder="Ask AI for help"
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={onInputKeyDown}
rows={1}
/>
<button
className="llm-send"
onClick={() => startFetch(0, 'chat')}
title="Send"
>
↑
</button>
<button
className="llm-close"
onClick={() => {
setPanelMode('hidden')
setAnchorShown(false)
}}
title="Close"
>
×
</button>
</div>
<div
className="llm-menu"
style={{ width: Math.min(220, panelRect.width - 36) }}
>
<div
style={{ fontSize: 12, color: '#98a3af', padding: '6px 8px' }}
>
Context Options
</div>
<div
className="llm-item"
onClick={() => startFetch(1, 'paraphrase')}
>
✏️ Paraphrase
</div>
<div
style={{ position: 'relative' }}
onMouseEnter={() => setSubmenu('style')}
onMouseLeave={() => setSubmenu(null)}
>
<div className="llm-item">🎨 Change style</div>
{submenu === 'style' && (
<div
style={{
position: 'absolute',
left: '104%',
top: 0,
minWidth: 120,
}}
className="llm-menu"
>
<div
className="llm-item"
onClick={() => startFetch(2, 'style')}
>
Scientific
</div>
<div
className="llm-item"
onClick={() => startFetch(3, 'style')}
>
Concise
</div>
<div
className="llm-item"
onClick={() => startFetch(4, 'style')}
>
Punchy
</div>
</div>
)}
</div>
<div
style={{ position: 'relative' }}
onMouseEnter={() => setSubmenu('splitjoin')}
onMouseLeave={() => setSubmenu(null)}
>
<div className="llm-item">🔀 Split/Join</div>
{submenu === 'splitjoin' && (
<div
style={{
position: 'absolute',
left: '104%',
top: 0,
minWidth: 120,
}}
className="llm-menu"
>
<div
className="llm-item"
onClick={() => startFetch(5, 'splitjoin')}
>
Split
</div>
<div
className="llm-item"
onClick={() => startFetch(6, 'splitjoin')}
>
Join
</div>
</div>
)}
</div>
<div
className="llm-item"
onClick={() => startFetch(7, 'summarize')}
>
📝 Summarize
</div>
<div
className="llm-item"
onClick={() => startFetch(8, 'explain')}
>
ℹ️ Explain
</div>
<div
style={{
height: 1,
background: 'rgba(255,255,255,0.03)',
margin: '8px 0',
}}
/>
<div
style={{ fontSize: 12, color: '#98a3af', padding: '6px 8px' }}
>
Generators
</div>
<div
className="llm-item"
onClick={() => startFetch(9, 'title')}
>
📄 Title Generator
</div>
<div
className="llm-item"
onClick={() => startFetch(10, 'abstract')}
>
📋 Abstract Generator
</div>
</div>
</div>
</div>
)}
{/* Chat panel */}
{anchorShown && panelMode === 'chat' && (
<div
ref={panelRef}
className="llm-panel"
style={{
top: panelRect.top,
left: panelRect.left,
width: panelRect.width,
}}
>
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<div className="llm-input-card">
<div className="llm-badge">AI</div>
<textarea
ref={inputRef}
className="llm-input"
placeholder="Ask AI for help"
value={query}
onChange={e => setQuery(e.target.value)}
onKeyDown={onInputKeyDown}
rows={1}
/>
<button
className="llm-send"
onClick={() => startFetch(0, 'chat')}
title="Send"
>
↑
</button>
<button
className="llm-close"
onClick={() => {
setPanelMode('hidden')
setAnchorShown(false)
setQuery('')
}}
title="Close"
>
×
</button>
</div>
<div className="llm-paraphrase-card">
<div style={{ fontSize: 13, color: '#9fb0c6' }}>
{kindTitleMap['chat']}
</div>
{loading ? (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 24,
}}
>
<Spinner />
<div style={{ marginLeft: 10, color: '#9aa4b2' }}>
Waiting for AI...
</div>
</div>
) : (
<>
<div
style={{
minHeight: 88,
maxHeight: contentMaxHeight,
overflowY: 'auto',
overflowX: 'hidden',
paddingRight: 8,
}}
>
<div
className="llm-result-html"
dangerouslySetInnerHTML={{ __html: renderedHtml }}
/>
</div>
<div className="llm-paraphrase-footer">
<button
className="llm-btn llm-primary"
onClick={() => {
setPanelMode('hidden')
setQuery('')
setResult('')
}}
>
Cancel
</button>
<button
className="llm-btn llm-primary"
onClick={() => insertCodeAfterSelection(result)}
disabled={!result}
>
Insert
</button>
<button
className="llm-btn llm-primary"
onClick={() => startFetch(0, 'chat')}
disabled={loading || !query.trim()}
>
Regenerate
</button>
</div>
</>
)}
</div>
</div>
</div>
)}
{/* Paraphrase panel */}
{panelMode === 'paraphrase' && (
<div
className="llm-panel"
style={{
top: panelRect.top,
left: panelRect.left,
width: panelRect.width,
}}
>
<div className="llm-paraphrase-card">
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: 8,
}}
>
<div style={{ fontSize: 13, color: '#9fb0c6' }}>
{kindTitleMap[kind]}
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button
className="llm-btn"
onClick={() => copyToClipboard(result)}
>
📋 Copy
</button>
<button
className="llm-btn"
onClick={() => setEditMode(s => !s)}
>
✏️ {editMode ? 'Done' : 'Edit'}
</button>
{kind !== 'title' && kind !== 'abstract' && (
<button
className="llm-btn"
onClick={() => setShowDiff(s => !s)}
>
{showDiff ? 'Hide' : 'Track'}
</button>
)}
</div>
</div>
{loading ? (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 24,
}}
>
<Spinner />
<div style={{ marginLeft: 10, color: '#9aa4b2' }}>
Waiting for AI...
</div>
</div>
) : (
<>
{editMode ? (
<textarea
ref={editRef}
value={result}
onChange={e => setResult(e.target.value)}
style={{
width: '100%',
minHeight: 88,
maxHeight: contentMaxHeight,
overflowY: 'auto',
overflowX: 'hidden',
padding: 10,
borderRadius: 8,
background: 'rgba(255,255,255,0.02)',
color: '#e6eef8',
outline: 'none',
resize: 'vertical',
boxSizing: 'border-box',
border: 'none',
}}
/>
) : (
<div style={{ minHeight: 88 }}>
<div
style={{
maxHeight: contentMaxHeight,
overflowY: 'auto',
overflowX: 'hidden',
paddingRight: 8,
}}
>
<div
className="llm-result-html"
style={{ color: '#e6eef8' }}
dangerouslySetInnerHTML={{ __html: renderedHtml }}
/>
</div>
</div>
)}
<div className="llm-paraphrase-footer">
<button
className="llm-btn llm-primary"
onClick={() => {
setPanelMode('hidden')
setAnchorShown(false)
setResult('')
setQuery('')
}}
>
Cancel
</button>
{kind === 'title' || kind === 'abstract' ? (
<button
className="llm-btn llm-primary"
onClick={() => insertCodeAfterSelection(result)}
disabled={loading || !result}
>
Insert
</button>
) : kind === 'summarize' || kind === 'explain' ? null : (
<button
className="llm-btn llm-primary"
onClick={() => replaceSelectionWith(result)}
disabled={loading || !result}
>
Replace
</button>
)}
<button
className="llm-btn llm-primary"
onClick={() => startFetch(kindToMode[kind], kind)}
disabled={loading}
>
Regenerate
</button>
</div>
</>
)}
</div>
</div>
)}
</div>
)
})
LLMToolbar.displayName = 'LLMToolbar'
export default LLMToolbar
@@ -0,0 +1,111 @@
import { memo, useCallback } from 'react'
import { useTranslation } from 'react-i18next'
import OLTooltip from '@/shared/components/ol/ol-tooltip'
import OLIconButton from '@/shared/components/ol/ol-icon-button'
import { useLLMChat } from '../hooks/use-llm-chat'
interface LogEntry {
key?: string
level: string
message?: string
content?: string
file?: string
line?: number
raw?: string
}
interface AskAIButtonProps {
logEntry?: LogEntry
id?: string
}
function PdfLogEntryAskAIButton({ logEntry }: AskAIButtonProps) {
const { t } = useTranslation()
const { modelsLoaded, hasModels } = useLLMChat()
const handleAskAI = useCallback(async () => {
if (!logEntry) return
try {
const errorMessage = formatErrorForLLM(logEntry)
// Open the LLM chat rail tab first
window.dispatchEvent(
new CustomEvent('ui:select-rail-tab', {
detail: { tab: 'llm-chat', open: true },
})
)
// Small delay to let the rail panel mount and the hook register its listener
setTimeout(() => {
window.dispatchEvent(
new CustomEvent('llm-chat-send-message', {
detail: { message: errorMessage },
})
)
}, 150)
} catch (err) {
console.error('[LLM] Failed to send error to AI:', err)
}
}, [logEntry])
// logEntry may be undefined in certain contexts; only show for errors
if (!logEntry || logEntry.level !== 'error') {
return null
}
if (!modelsLoaded || !hasModels) {
return null
}
return (
<OLTooltip
id={`ask-ai-${logEntry.key}`}
description={t('ask_ai_about_error', 'Ask AI about this error')}
overlayProps={{ placement: 'bottom' }}
>
<OLIconButton
onClick={handleAskAI}
variant="ghost"
icon="smart_toy"
accessibilityLabel={t(
'ask_ai_about_error',
'Ask AI about this error'
)}
/>
</OLTooltip>
)
}
function formatErrorForLLM(logEntry: LogEntry): string {
const parts = [
'🔴 **LaTeX Compilation Error**',
'',
'**Error Message:**',
logEntry.message || logEntry.content || 'Unknown error',
'',
]
if (logEntry.file) {
parts.push(`**File:** \`${logEntry.file}\``)
}
if (logEntry.line) {
parts.push(`**Line:** ${logEntry.line}`)
}
if (logEntry.raw && logEntry.raw !== logEntry.message) {
parts.push('', '**Full Error Details:**', '```', logEntry.raw, '```', '')
}
parts.push(
'**Please help me:**',
"1. Explain what this error means in simple terms",
"2. Show me exactly what's wrong in my code",
'3. Provide the corrected code',
'4. Explain how to avoid this error in the future'
)
return parts.join('\n')
}
export default memo(PdfLogEntryAskAIButton)
@@ -0,0 +1,6 @@
// Extension point wrapper for sourceEditorExtensions
// Provides the CodeMirror inline completion extension.
// Must be exported as a function (options) => Extension to match the consumer contract.
import { inlineCompletionExtension } from '../components/llm-completion'
export const extension = (_options: Record<string, any>) => inlineCompletionExtension()
@@ -0,0 +1,21 @@
import { useCallback, useRef, useState } from 'react'
import { ImperativePanelHandle } from 'react-resizable-panels'
export const useLLMChatPane = () => {
const [isOpen, setIsOpen] = useState(false)
const [resizing, setResizing] = useState(false)
const panelRef = useRef<ImperativePanelHandle>(null)
const togglePane = useCallback(() => {
setIsOpen(value => !value)
}, [])
return {
isOpen,
setIsOpen,
panelRef,
resizing,
setResizing,
togglePane,
}
}
@@ -0,0 +1,291 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import getMeta from '@/utils/meta'
interface Message {
role: 'system' | 'user' | 'assistant'
content: string
}
interface LLMModel {
id: string
name: string
isDefault: boolean
isPersonal?: boolean
label?: string
}
interface LLMResponse {
choices: Array<{
message: {
content: string
}
}>
}
const 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!`
export const useLLMChat = () => {
const projectId = getMeta('ol-project_id')
const [messages, setMessages] = useState<Message[]>([
{
role: 'system',
content: SYSTEM_PROMPT,
},
])
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [models, setModels] = useState<LLMModel[]>([])
const [selectedModel, setSelectedModel] = useState<string>('')
const [lastUserMessage, setLastUserMessage] = useState<string>('')
const [modelsLoaded, setModelsLoaded] = useState(false)
const [modelsError, setModelsError] = useState(false)
const abortControllerRef = useRef<AbortController | null>(null)
const messagesRef = useRef(messages)
useEffect(() => {
messagesRef.current = messages
}, [messages])
useEffect(() => {
async function fetchModels() {
if (!projectId) {
console.warn('[LLMChat] No project ID available, skipping model fetch')
return
}
try {
const response = await fetch(`/project/${projectId}/llm/models`)
if (!response.ok) {
throw new Error(`[LLMChat] Models endpoint returned ${response.status}`)
}
const data = await response.json()
const { llmAllowUserSettings } = getMeta('ol-ExposedSettings') || {}
let modelsFromBackend: LLMModel[] = data.models || []
if (!llmAllowUserSettings) {
modelsFromBackend = modelsFromBackend.filter(
(m: LLMModel) =>
!m.isPersonal && !(m.id && m.id.startsWith('personal-'))
)
}
setModels(modelsFromBackend)
setModelsError(false)
const defaultModel =
modelsFromBackend.find((m: LLMModel) => m.isDefault) ||
modelsFromBackend[0]
setSelectedModel(defaultModel?.id || '')
setModelsLoaded(true)
} catch (err) {
console.error('[LLMChat] Failed to fetch models:', err)
setModels([])
setSelectedModel('')
setModelsError(true)
// Don't set modelsLoaded=true on error so button remains visible
}
}
fetchModels()
}, [projectId])
const sendMessage = useCallback(
async (userMessage: string) => {
const newMessages: Message[] = [
...messagesRef.current,
{ role: 'user', content: userMessage },
]
setMessages(newMessages)
setIsLoading(true)
setError(null)
setLastUserMessage(userMessage)
abortControllerRef.current = new AbortController()
try {
const url = `/project/${projectId}/llm/chat`
const csrfToken = getMeta('ol-csrfToken')
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
},
body: JSON.stringify({
messages: newMessages,
model: selectedModel,
}),
signal: abortControllerRef.current.signal,
credentials: 'same-origin',
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const data: LLMResponse = await response.json()
if (!data.choices || !data.choices[0]) {
throw new Error('Invalid response format from LLM API')
}
const assistantMessage: Message = {
role: 'assistant',
content: data.choices[0].message.content,
}
setMessages([...newMessages, assistantMessage])
} catch (err: any) {
if (err.name === 'AbortError') {
const abortMsg: Message = {
role: 'assistant',
content: '⚠️ Request stopped by user.',
}
setMessages([...newMessages, abortMsg])
} else {
const errorMessage =
err instanceof Error ? err.message : 'Unknown error'
setError(errorMessage)
const errorMsg: Message = {
role: 'assistant',
content: `❌ Error: ${errorMessage}\n\nPlease check the console for details.`,
}
setMessages([...newMessages, errorMsg])
}
} finally {
setIsLoading(false)
abortControllerRef.current = null
}
},
[projectId, selectedModel]
)
const stopGeneration = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort()
}
}, [])
const rerunLastMessage = useCallback(() => {
if (!lastUserMessage) return
const currentMessages = messagesRef.current
let foundIndex = -1
for (let i = currentMessages.length - 1; i >= 0; i--) {
if (
currentMessages[i].role === 'user' &&
currentMessages[i].content === lastUserMessage
) {
foundIndex = i
break
}
}
if (foundIndex === -1) {
sendMessage(lastUserMessage)
return
}
const messagesBeforeRerun = currentMessages.slice(0, foundIndex)
setMessages(messagesBeforeRerun)
setTimeout(() => {
sendMessage(lastUserMessage)
}, 50)
}, [lastUserMessage, sendMessage])
const clearMessages = useCallback(() => {
setMessages([
{
role: 'system',
content: SYSTEM_PROMPT,
},
])
setError(null)
setLastUserMessage('')
}, [])
// Listen for messages from error log "Ask AI" buttons
useEffect(() => {
const handleSendMessage = (event: CustomEvent<{ message: string }>) => {
sendMessage(event.detail.message)
}
window.addEventListener(
'llm-chat-send-message',
handleSendMessage as EventListener
)
return () => {
window.removeEventListener(
'llm-chat-send-message',
handleSendMessage as EventListener
)
}
}, [sendMessage])
return {
messages,
isLoading,
error,
sendMessage,
stopGeneration,
rerunLastMessage,
clearMessages,
models,
selectedModel,
setSelectedModel,
canRerun: !!lastUserMessage,
modelsLoaded,
modelsError,
hasModels: models.length > 0,
}
}
@@ -0,0 +1,8 @@
import { renderInReactLayout } from '@/react'
import '@/utils/meta'
import '@/utils/webpack-public-path'
import '@/infrastructure/error-reporter'
import '@/i18n'
import LLMAdminSettingsPage from '../../components/llm-admin-settings-page'
renderInReactLayout('llm-admin-settings-root', () => <LLMAdminSettingsPage />)
@@ -0,0 +1,8 @@
import { renderInReactLayout } from '@/react'
import '@/utils/meta'
import '@/utils/webpack-public-path'
import '@/infrastructure/error-reporter'
import '@/i18n'
import LLMSettingsPage from '../../components/llm-settings-page'
renderInReactLayout('settings-page-root', () => <LLMSettingsPage />)
@@ -0,0 +1,666 @@
// LLM Chat styling matching the regular chat theme
:root {
--llm-chat-bg: var(--neutral-80);
--llm-chat-color: var(--white);
--llm-chat-text-color: var(--neutral-90);
--llm-chat-instructions-color: var(--neutral-20);
--llm-chat-new-message-bg: var(--neutral-70);
--llm-chat-new-message-textarea-color: var(--neutral-90);
--llm-chat-new-message-textarea-bg: var(--neutral-20);
--llm-chat-new-message-textarea-border: var(--editor-border-color);
--llm-chat-new-message-border: var(--editor-border-color);
--llm-chat-message-user-bg: var(--green-70);
--llm-chat-message-user-text: var(--white);
--llm-chat-message-assistant-bg: var(--bg-light-secondary);
--llm-chat-message-assistant-text: var(--neutral-90);
--llm-chat-code-bg: rgba(0, 0, 0, 0.1);
--llm-chat-code-text: var(--neutral-90);
/* LLM-specific fallbacks for suggestion items and selector */
--llm-chat-suggestions-bg: var(--bg-light-secondary, #f3f4f6);
--llm-chat-suggestions-color: var(--content-primary, #111);
--llm-chat-suggestions-title-color: var(--content-primary, #111);
--llm-chat-suggestion-item-bg: var(--llm-chat-new-message-textarea-bg, #fff);
--llm-chat-suggestion-item-color: var(--content-primary, #111);
--llm-model-selector-label-color: var(--content-primary, #111);
--llm-model-selector-select-color: var(--content-primary, #111);
/* Max width used by suggestions and model selector */
--llm-suggestions-max-width: 350px;
}
.ide-redesign-main {
--llm-chat-bg: var(--bg-dark-primary);
--llm-chat-color: var(--content-primary);
--llm-chat-text-color: var(--content-primary);
--llm-chat-instructions-color: var(--content-primary-dark);
--llm-chat-new-message-bg: var(--bg-dark-primary);
--llm-chat-new-message-textarea-color: var(--content-primary-dark);
--llm-chat-new-message-textarea-bg: var(--bg-dark-primary);
--llm-chat-message-user-bg: var(--green-70);
--llm-chat-message-user-text: var(--white);
--llm-chat-message-assistant-bg: var(--bg-light-secondary);
--llm-chat-message-assistant-text: var(--content-primary);
--llm-chat-code-bg: rgba(0, 0, 0, 0.2);
--llm-chat-code-text: var(--content-primary);
/* Dark-theme LLM overrides */
--llm-chat-suggestions-bg: var(--bg-dark-secondary, #111827);
--llm-chat-suggestions-color: var(--content-primary, #e6eef7);
--llm-chat-suggestions-title-color: var(--content-primary, #e6eef7);
--llm-chat-suggestion-item-bg: var(--llm-chat-new-message-textarea-bg, #111827);
--llm-chat-suggestion-item-color: var(--content-primary, #e6eef7);
--llm-model-selector-label-color: var(--content-primary, #e6eef7);
--llm-model-selector-select-color: var(--content-primary, #e6eef7);
}
.llm-rail-panel {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
.chat {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
}
.llm-chat-container {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
flex: 1;
overflow: hidden;
background-color: var(--llm-chat-bg);
color: var(--llm-chat-color);
/* collaborator-style wrapper used when embedding MessageInput in AI pane */
.llm-chat-collab-wrapper {
margin-top: var(--spacing-03);
textarea {
width: 100%;
box-sizing: border-box;
max-width: 100%;
overflow: auto;
resize: none;
}
}
/* Ensure direct children don't overflow the container */
>* {
max-width: 100%;
box-sizing: border-box;
}
align-items: center;
padding: var(--spacing-03) var(--spacing-04) var(--spacing-03) var(--spacing-02);
background-color: var(--llm-chat-new-message-bg);
border-bottom: 1px solid var(--llm-chat-new-message-border);
gap: var(--spacing-03);
}
/* Header for LLM chat: model selector on the left, action buttons to the right */
.llm-chat-header {
display: flex;
align-items: center;
gap: var(--spacing-03);
width: 100%;
}
.llm-model-selector {
display: flex;
align-items: center;
gap: var(--spacing-03);
/* Keep selector left-aligned and constrain width */
flex: 0 1 auto;
max-width: var(--llm-suggestions-max-width);
min-width: 0;
/* allow shrinking in narrow containers */
label {
font-size: var(--font-size-02);
font-weight: 600;
color: var(--content-primary-themed);
margin: 0 0 var(--spacing-00) var(--spacing-02);
white-space: nowrap;
}
/* Styles for the select control inside the model selector */
select {
padding: var(--spacing-02) var(--spacing-03);
padding-right: calc(var(--spacing-03) + 18px);
/* room for caret */
border: 1px solid var(--llm-chat-new-message-textarea-border);
border-radius: var(--border-radius-base);
background-color: var(--llm-chat-new-message-textarea-bg, #fff);
/* ensure the option text is readable in all themes */
color: var(--content-primary-themed);
font-size: var(--font-size-02);
outline: none;
border-color: var(--border-primary);
min-width: 0;
width: 200px;
flex: 0 1 auto;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background-image: linear-gradient(45deg, transparent 50%, currentColor 50%), linear-gradient(135deg, currentColor 50%, transparent 50%);
background-position: calc(100% - 12px) calc(1em + 2px), calc(100% - 7px) calc(1em + 2px);
background-size: 6px 6px, 6px 6px;
background-repeat: no-repeat;
&:focus {
border-color: var(--border-primary);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
@media (max-width: 480px) {
width: 100%;
min-width: 0;
}
}
}
// Action buttons container
.llm-action-buttons {
display: flex;
gap: var(--spacing-02);
align-items: center;
margin-left: auto;
/* push actions to the far right */
}
.llm-action-button {
background-color: var(--llm-action-button-bg, transparent);
border: 1px solid var(--llm-chat-new-message-textarea-border);
padding: var(--spacing-02);
cursor: pointer;
border-radius: var(--border-radius-base);
display: flex;
align-items: center;
justify-content: center;
color: var(--content-primary-themed);
/* ensure high-contrast icon color */
opacity: 1;
transition: all 0.2s ease;
&:hover {
background-color: var(--bg-light-tertiary);
color: var(--content-primary-themed);
border-color: var(--border-secondary);
}
&:focus-visible {
outline: 2px solid var(--border-primary);
outline-offset: 2px;
}
&:active {
transform: scale(0.95);
}
.material-symbols {
font-size: 18px;
color: inherit;
/* inherit high-contrast color */
}
}
.llm-chat-welcome {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
text-align: center;
/* Use flex so the welcome panel shares space and doesn't push the input offscreen */
flex: 1;
min-height: 0;
.llm-welcome-icon {
display: flex;
align-items: center;
justify-content: center;
width: 80px;
height: 80px;
border-radius: 50%;
background-color: var(--bg-secondary-themed);
color: var(--content-primary-themed);
font-size: 36px;
margin-bottom: 20px;
animation: float 3s ease-in-out infinite;
}
.llm-welcome-icon.material-symbols {
font-size: 36px;
line-height: 1;
}
h3 {
font-size: var(--font-size-02);
font-weight: 600;
color: var(--content-primary);
margin-bottom: 10px;
}
>p {
font-size: var(--font-size-02);
color: var(--content-primary-themed);
margin-bottom: 30px;
max-width: 300px;
line-height: var(--line-height-02);
}
.llm-suggestions {
background-color: var(--llm-chat-suggestions-bg);
border-radius: var(--border-radius-large);
padding: 20px;
margin-bottom: 30px;
max-width: var(--llm-suggestions-max-width);
width: 100%;
.llm-suggestions-title {
font-weight: 600;
color: var(--content-primary-themed);
margin-bottom: 12px;
font-size: var(--font-size-02);
text-align: left;
}
ul {
list-style: none;
padding: 0;
margin: 0;
text-align: left;
li {
padding: var(--spacing-03) var(--spacing-04);
margin-bottom: var(--spacing-02);
background-color: var(--llm-chat-suggestion-item-bg);
border-radius: var(--border-radius-base);
border: 1px solid var(--llm-chat-new-message-textarea-border);
font-size: var(--font-size-02);
line-height: var(--line-height-02);
color: var(--content-primary-themed);
transition: all 0.2s ease;
cursor: default;
word-break: break-word;
/* keep hover a no-op so suggestions don't change color on hover */
&:hover {
background-color: var(--llm-chat-suggestion-item-bg);
}
&:last-child {
margin-bottom: 0;
}
}
}
}
.llm-status-badge {
display: inline-flex;
align-items: center;
gap: var(--spacing-03);
padding: var(--spacing-03) var(--spacing-05);
background-color: var(--bg-warning-light);
border: 1px solid var(--border-warning);
border-radius: 20px;
color: var(--content-warning);
font-size: var(--font-size-02);
font-weight: 500;
.material-symbols {
font-size: 18px;
}
}
}
// Chat messages container
.llm-chat-messages {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
background-color: var(--llm-chat-bg);
padding: calc(var(--line-height-03) / 2);
.llm-message {
display: flex;
flex-direction: column;
gap: var(--spacing-01);
margin-bottom: var(--line-height-03);
.message-container {
display: flex;
max-width: 100%;
flex-wrap: wrap;
/* allow content and metadata to wrap rather than overflow */
.message-content {
background-color: var(--llm-chat-message-assistant-bg);
border-radius: var(--border-radius-large);
padding: var(--spacing-04) var(--spacing-05);
width: 100%;
max-width: 100%;
box-sizing: border-box;
overflow-x: hidden;
/* hide overflow; pre will scroll internally */
word-break: break-word;
/* prevent long words from overflowing */
p {
margin-bottom: calc(var(--line-height-03) / 2);
white-space: pre-wrap;
/* wrap long paragraphs inside the message content */
&:last-child {
margin-bottom: 0;
}
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 600;
margin-top: var(--spacing-04);
margin-bottom: var(--spacing-02);
color: var(--llm-chat-message-assistant-text);
}
h3 {
font-size: var(--font-size-03);
}
ul,
ol {
margin-left: var(--spacing-05);
margin-bottom: calc(var(--line-height-03) / 2);
li {
margin-bottom: var(--spacing-01);
}
}
code {
background-color: var(--llm-chat-code-bg);
padding: 2px 6px;
border-radius: var(--border-radius-base-sm);
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.9em;
color: var(--llm-chat-code-text);
}
pre {
background-color: var(--neutral-90);
border-radius: var(--border-radius-base);
padding: var(--spacing-04);
margin: var(--spacing-03) 0;
overflow-x: auto;
max-width: 100%;
code {
background: none;
padding: 0;
color: var(--white);
display: block;
white-space: pre;
/* preserve whitespace but allow scrolling */
overflow-x: auto;
word-break: normal;
}
}
hr {
border: none;
border-top: 1px solid var(--border-secondary);
margin: var(--spacing-04) 0;
}
strong {
font-weight: 600;
}
em {
font-style: italic;
}
a {
color: var(--link-primary);
text-decoration: underline;
&:hover {
color: var(--link-primary-hover);
}
}
blockquote {
border-left: 3px solid var(--border-primary);
padding-left: var(--spacing-04);
margin: var(--spacing-03) 0;
color: var(--content-secondary);
font-style: italic;
}
table {
border-collapse: collapse;
margin: var(--spacing-03) 0;
width: 100%;
th,
td {
border: 1px solid var(--border-secondary);
padding: var(--spacing-02) var(--spacing-03);
text-align: left;
}
th {
background-color: var(--bg-light-tertiary);
font-weight: 600;
}
}
}
}
&.llm-message-user .message-container {
align-self: flex-end;
justify-content: flex-end;
.message-content {
background-color: var(--llm-chat-message-user-bg);
color: var(--llm-chat-message-user-text);
h1,
h2,
h3,
h4,
h5,
h6 {
color: var(--llm-chat-message-user-text);
}
code {
background-color: rgba(0, 0, 0, 0.3);
color: var(--white);
}
pre {
background-color: rgba(0, 0, 0, 0.4);
code {
background: none;
color: var(--white);
}
}
}
}
&.llm-message-assistant .message-container {
align-self: flex-start;
justify-content: flex-start;
}
// Loading indicator with stop button
.llm-message-loading {
display: flex;
align-items: center;
gap: var(--spacing-03);
padding: var(--spacing-03) var(--spacing-05);
background-color: var(--llm-chat-message-assistant-bg);
border-radius: var(--border-radius-large);
font-style: italic;
color: var(--llm-chat-message-assistant-text);
.loading-icon {
animation: spin 2s linear infinite;
color: var(--llm-chat-message-assistant-text);
}
span {
flex: 1;
color: var(--llm-chat-message-assistant-text);
}
}
}
}
// Stop button styling
.llm-stop-button {
background-color: var(--alert-danger-bg);
border: 1px solid var(--alert-danger-border);
color: var(--alert-danger);
padding: var(--spacing-02) var(--spacing-04);
border-radius: var(--border-radius-base);
cursor: pointer;
display: flex;
align-items: center;
gap: var(--spacing-02);
font-size: var(--font-size-01);
font-weight: 600;
transition: all 0.2s ease;
white-space: nowrap;
&:hover {
background-color: var(--alert-danger);
color: var(--white);
}
&:active {
transform: scale(0.95);
}
.material-symbols {
font-size: 16px;
}
}
// Input form
.llm-chat-input-form {
$new-message-height: 80px;
height: $new-message-height;
background-color: var(--llm-chat-new-message-bg);
padding: calc(var(--line-height-03) / 4);
border-top: 1px solid var(--llm-chat-new-message-border);
display: flex;
gap: var(--spacing-03);
align-items: center;
.llm-chat-textarea {
flex: 1;
overflow: auto;
resize: none;
border-radius: var(--border-radius-base);
border: 1px solid var(--llm-chat-new-message-textarea-border);
height: 100%;
color: var(--llm-chat-new-message-textarea-color);
font-size: var(--font-size-02);
padding: calc(var(--line-height-03) / 4);
background-color: var(--llm-chat-new-message-textarea-bg);
font-family: inherit;
&:focus {
outline: none;
border-color: var(--border-primary);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
.llm-chat-send-button {
padding: var(--spacing-03) var(--spacing-05);
background-color: var(--bg-accent-01);
color: var(--white);
border: none;
border-radius: var(--border-radius-base);
cursor: pointer;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
min-width: 60px;
transition: background-color 0.2s ease;
&:hover:not(:disabled) {
background-color: var(--bg-accent-02);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.material-symbols {
font-size: 20px;
}
}
}
@keyframes float {
0%,
100% {
transform: translateY(0px);
}
50% {
transform: translateY(-10px);
}
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
// Spin animation for loading icon
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
+51
View File
@@ -0,0 +1,51 @@
import Settings from '@overleaf/settings'
import logger from '@overleaf/logger'
let LLMModule = {}
// LLM module is enabled when LLM_ENABLED env is set or llm.enabled in settings
const llmEnabled =
process.env.LLM_ENABLED === 'true' ||
(Settings.llm && Settings.llm.enabled !== false)
logger.info(
{
LLM_ENABLED: process.env.LLM_ENABLED,
LLM_API_URL: process.env.LLM_API_URL ? '(set)' : '(not set)',
LLM_API_KEY: process.env.LLM_API_KEY ? '(set)' : '(not set)',
LLM_MODEL_NAME: process.env.LLM_MODEL_NAME,
LLM_ALLOW_USER_SETTINGS: process.env.LLM_ALLOW_USER_SETTINGS,
settingsLlm: Settings.llm,
llmEnabled,
},
'[LLM] Module init: environment check'
)
if (llmEnabled) {
const { default: LLMRouter } = await import('./app/src/LLMRouter.mjs')
// Configure LLM settings from environment
Settings.llm = Settings.llm || {}
Settings.llm.enabled = true
Settings.llm.allowUserSettings =
process.env.LLM_ALLOW_USER_SETTINGS === 'true' ||
(Settings.llm.allowUserSettings ?? true)
logger.info(
{
llmSettings: Settings.llm,
routerType: typeof LLMRouter,
hasApply: typeof LLMRouter?.apply,
},
'[LLM] Module loaded successfully'
)
LLMModule = {
name: 'llm',
router: LLMRouter,
}
} else {
logger.info({}, '[LLM] Module NOT loaded (disabled)')
}
export default LLMModule
+1
View File
@@ -55,4 +55,5 @@ export type ExposedSettings = {
githubSyncEnabled: boolean
zoteroEnabled: boolean
enablePandocConversions: boolean
llmAllowUserSettings?: boolean
}