mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 13:13:45 +01:00
LLM: additions by @alelotti96
- Made the three hardcoded qwen3-32b fallbacks env-configurable.
- AES-256-GCM encryption of per-user API keys.
- "Ask AI" on a selection follows the model picked in the chat dropdown.
- Per-feature enable/disable toggles (chat / completion / review) enforced backend-side, not just hidden in the UI
- All prompts editable from the admin page: the chat system prompt, the Ask AI behaviour prompt, the compile-error help prompt, the review system prompt, and the ten Ask AI action templates (paraphrase, academic, concise, summarize, ...), each using a {{selection}} placeholder. An empty field falls back to the shipped default.
- A document compliance review: it sends the whole project to the model and checks it against admin-defined rubrics (thesis or internship guidelines), returning a per-requirement report.
This commit is contained in:
@@ -254,6 +254,7 @@ export const UserSchema = new Schema(
|
||||
llmApiKey: { type: String, default: '' },
|
||||
llmModelName: { type: String, default: '' },
|
||||
llmApiUrl: { type: String, default: '' },
|
||||
llmCompletionModel: { type: String, default: '' },
|
||||
},
|
||||
{ minimize: false }
|
||||
)
|
||||
|
||||
@@ -150,6 +150,8 @@ const ReviewTooltipMenuContent = memo<{ onAddComment: () => void; onHide: () =>
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
const llmEnabled = !!(getMeta('ol-ExposedSettings') as any)?.llmEnabled
|
||||
// hide Ask AI when the admin disabled the chat feature at runtime
|
||||
const llmChatEnabled = (window as any).__llmChatEnabled !== false
|
||||
|
||||
const handleAddCommentClick = useCallback(() => {
|
||||
sendMB('add-comment', {
|
||||
@@ -274,7 +276,7 @@ const ReviewTooltipMenuContent = memo<{ onAddComment: () => void; onHide: () =>
|
||||
<MaterialIcon type="chat" />
|
||||
{t('add_comment')}
|
||||
</button>
|
||||
{llmEnabled && (
|
||||
{llmEnabled && llmChatEnabled && (
|
||||
<>
|
||||
<div className="review-tooltip-menu-divider" />
|
||||
<button
|
||||
|
||||
@@ -2,11 +2,28 @@ import logger from '@overleaf/logger'
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
import { expressify } from '@overleaf/promise-utils'
|
||||
import { encryptSecret, decryptSecret } from './LLMCrypto.mjs' // overleaf-lab: at-rest encryption of admin API key
|
||||
import {
|
||||
DEFAULT_ASK_AI_SYSTEM_PROMPT,
|
||||
DEFAULT_ERROR_PROMPT,
|
||||
DEFAULT_REVIEW_SYSTEM_PROMPT,
|
||||
DEFAULT_ASK_AI_ACTION_PROMPTS,
|
||||
mergeActionPrompts,
|
||||
} from './LLMPrompts.mjs' // overleaf-lab: editable prompt defaults + merge helper
|
||||
|
||||
// Persist admin LLM settings in the same volume used by Overleaf data
|
||||
const ADMIN_SETTINGS_PATH = process.env.LLM_ADMIN_SETTINGS_PATH ||
|
||||
'/var/lib/overleaf/data/llm-admin-settings.json'
|
||||
|
||||
// overleaf-lab: fallback for the review answer budget when the admin has not set one.
|
||||
// Mirrors LLMComplianceController's REVIEW_MAX_TOKENS default (env override, else
|
||||
// 12000). Duplicated here on purpose: importing it would make the two controllers
|
||||
// import each other, since the compliance one already imports this module.
|
||||
const DEFAULT_REVIEW_MAX_TOKENS =
|
||||
Number.parseInt(process.env.LLM_REVIEW_MAX_TOKENS, 10) > 0
|
||||
? Number.parseInt(process.env.LLM_REVIEW_MAX_TOKENS, 10)
|
||||
: 12000
|
||||
|
||||
async function readAdminSettings() {
|
||||
try {
|
||||
const raw = await fs.readFile(ADMIN_SETTINGS_PATH, 'utf8')
|
||||
@@ -28,29 +45,91 @@ async function writeAdminSettings(data) {
|
||||
}
|
||||
}
|
||||
|
||||
async function adminSettingsPage(req, res) {
|
||||
// overleaf-lab: the shared LLM backend can be configured either via this admin
|
||||
// settings JSON file OR via environment variables (LLM_API_URL / LLM_API_KEY /
|
||||
// LLM_MODEL_NAME). The chat already falls back to env; expose the same fallback
|
||||
// here so the admin page and the model scan reflect an env-only configuration
|
||||
// instead of looking empty.
|
||||
function envModelList() {
|
||||
const raw = process.env.LLM_AVAILABLE_MODELS || process.env.LLM_MODEL_NAME || ''
|
||||
return raw
|
||||
.split(',')
|
||||
.map(m => m.trim())
|
||||
.filter(m => m.length > 0)
|
||||
}
|
||||
|
||||
// Effective settings for display: the JSON value, else the env fallback, plus
|
||||
// flags telling the UI which values are inherited from the environment. The API
|
||||
// key value is never returned, only whether one is set.
|
||||
async function buildDisplaySettings() {
|
||||
const settings = await readAdminSettings()
|
||||
const pugPath = new URL('../../app/views/llm-admin-settings.pug', import.meta.url).pathname
|
||||
res.render(pugPath, {
|
||||
const envModels = envModelList()
|
||||
const jsonHasModels =
|
||||
Array.isArray(settings.allowedModels) && settings.allowedModels.length > 0
|
||||
return {
|
||||
systemPrompt: settings.systemPrompt || '',
|
||||
llmApiUrl: settings.llmApiUrl || '',
|
||||
hasLlmApiKey: !!settings.llmApiKey,
|
||||
allowedModels: settings.allowedModels || [],
|
||||
})
|
||||
llmApiUrl: settings.llmApiUrl || process.env.LLM_API_URL || '',
|
||||
hasLlmApiKey: !!(settings.llmApiKey || process.env.LLM_API_KEY),
|
||||
allowedModels: jsonHasModels ? settings.allowedModels : envModels,
|
||||
completionModel: settings.completionModel || '',
|
||||
llmApiUrlFromEnv: !settings.llmApiUrl && !!process.env.LLM_API_URL,
|
||||
hasApiKeyFromEnv: !settings.llmApiKey && !!process.env.LLM_API_KEY,
|
||||
allowedModelsFromEnv: !jsonHasModels && envModels.length > 0,
|
||||
// overleaf-lab: document compliance review settings
|
||||
complianceRubrics: Array.isArray(settings.complianceRubrics) ? settings.complianceRubrics : [],
|
||||
reviewModel: settings.reviewModel || '',
|
||||
maxContextTokens: settings.maxContextTokens || 32000,
|
||||
reviewMaxTokens: settings.reviewMaxTokens || DEFAULT_REVIEW_MAX_TOKENS,
|
||||
// overleaf-lab: per-feature enable flags; absent field defaults to true so
|
||||
// existing installs keep every feature on.
|
||||
chatEnabled: settings.chatEnabled !== false,
|
||||
completionEnabled: settings.completionEnabled !== false,
|
||||
reviewEnabled: settings.reviewEnabled !== false,
|
||||
// overleaf-lab: editable prompt overrides. Show the EFFECTIVE value (the
|
||||
// admin override when set, else the shipped default) plus the pristine
|
||||
// defaults so the admin page can offer a reset-to-default button.
|
||||
askAiSystemPrompt: settings.askAiSystemPrompt || DEFAULT_ASK_AI_SYSTEM_PROMPT,
|
||||
errorPrompt: settings.errorPrompt || DEFAULT_ERROR_PROMPT,
|
||||
reviewSystemPrompt: settings.reviewSystemPrompt || DEFAULT_REVIEW_SYSTEM_PROMPT,
|
||||
askAiActionPrompts: mergeActionPrompts(settings.askAiActionPrompts),
|
||||
promptDefaults: {
|
||||
askAiSystemPrompt: DEFAULT_ASK_AI_SYSTEM_PROMPT,
|
||||
errorPrompt: DEFAULT_ERROR_PROMPT,
|
||||
reviewSystemPrompt: DEFAULT_REVIEW_SYSTEM_PROMPT,
|
||||
askAiActionPrompts: DEFAULT_ASK_AI_ACTION_PROMPTS,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function adminSettingsPage(req, res) {
|
||||
const pugPath = new URL('../../app/views/llm-admin-settings.pug', import.meta.url).pathname
|
||||
res.render(pugPath, await buildDisplaySettings())
|
||||
}
|
||||
|
||||
async function getAdminSettings(req, res) {
|
||||
const settings = await readAdminSettings()
|
||||
res.json({
|
||||
systemPrompt: settings.systemPrompt || '',
|
||||
llmApiUrl: settings.llmApiUrl || '',
|
||||
hasLlmApiKey: !!settings.llmApiKey,
|
||||
allowedModels: settings.allowedModels || [],
|
||||
})
|
||||
res.json(await buildDisplaySettings())
|
||||
}
|
||||
|
||||
async function saveAdminSettings(req, res) {
|
||||
const { systemPrompt, llmApiUrl, llmApiKey, allowedModels } = req.body
|
||||
const {
|
||||
systemPrompt,
|
||||
llmApiUrl,
|
||||
llmApiKey,
|
||||
allowedModels,
|
||||
completionModel,
|
||||
complianceRubrics,
|
||||
reviewModel,
|
||||
maxContextTokens,
|
||||
reviewMaxTokens,
|
||||
chatEnabled,
|
||||
completionEnabled,
|
||||
reviewEnabled,
|
||||
// overleaf-lab: editable prompt overrides.
|
||||
askAiSystemPrompt,
|
||||
errorPrompt,
|
||||
reviewSystemPrompt,
|
||||
askAiActionPrompts,
|
||||
} = req.body
|
||||
|
||||
if (typeof systemPrompt !== 'string') {
|
||||
return res.status(400).json({ error: 'systemPrompt must be a string' })
|
||||
@@ -69,16 +148,183 @@ async function saveAdminSettings(req, res) {
|
||||
return res.status(400).json({ error: 'allowedModels must be an array' })
|
||||
}
|
||||
|
||||
// overleaf-lab: validate the document compliance review settings.
|
||||
if (complianceRubrics !== undefined && !Array.isArray(complianceRubrics)) {
|
||||
return res.status(400).json({ error: 'complianceRubrics must be an array' })
|
||||
}
|
||||
if (reviewModel !== undefined && typeof reviewModel !== 'string') {
|
||||
return res.status(400).json({ error: 'reviewModel must be a string' })
|
||||
}
|
||||
|
||||
// overleaf-lab: per-feature enable flags are optional booleans. When provided
|
||||
// they must be booleans; when omitted the existing value is preserved below.
|
||||
if (chatEnabled !== undefined && typeof chatEnabled !== 'boolean') {
|
||||
return res.status(400).json({ error: 'chatEnabled must be a boolean' })
|
||||
}
|
||||
if (completionEnabled !== undefined && typeof completionEnabled !== 'boolean') {
|
||||
return res.status(400).json({ error: 'completionEnabled must be a boolean' })
|
||||
}
|
||||
if (reviewEnabled !== undefined && typeof reviewEnabled !== 'boolean') {
|
||||
return res.status(400).json({ error: 'reviewEnabled must be a boolean' })
|
||||
}
|
||||
|
||||
// overleaf-lab: editable prompt overrides. Each scalar prompt, when provided,
|
||||
// must be a string capped at 8000 chars. An empty string is allowed and means
|
||||
// "fall back to default" (buildDisplaySettings/getLLMPrompts use `|| DEFAULT`).
|
||||
if (askAiSystemPrompt !== undefined && typeof askAiSystemPrompt !== 'string') {
|
||||
return res.status(400).json({ error: 'askAiSystemPrompt must be a string' })
|
||||
}
|
||||
if (typeof askAiSystemPrompt === 'string' && askAiSystemPrompt.length > 8000) {
|
||||
return res.status(400).json({ error: 'askAiSystemPrompt must be 8000 characters or fewer' })
|
||||
}
|
||||
if (errorPrompt !== undefined && typeof errorPrompt !== 'string') {
|
||||
return res.status(400).json({ error: 'errorPrompt must be a string' })
|
||||
}
|
||||
if (typeof errorPrompt === 'string' && errorPrompt.length > 8000) {
|
||||
return res.status(400).json({ error: 'errorPrompt must be 8000 characters or fewer' })
|
||||
}
|
||||
if (reviewSystemPrompt !== undefined && typeof reviewSystemPrompt !== 'string') {
|
||||
return res.status(400).json({ error: 'reviewSystemPrompt must be a string' })
|
||||
}
|
||||
if (typeof reviewSystemPrompt === 'string' && reviewSystemPrompt.length > 8000) {
|
||||
return res.status(400).json({ error: 'reviewSystemPrompt must be 8000 characters or fewer' })
|
||||
}
|
||||
// overleaf-lab: action prompts, when provided, must be a plain (non-array) object.
|
||||
if (
|
||||
askAiActionPrompts !== undefined &&
|
||||
(typeof askAiActionPrompts !== 'object' ||
|
||||
askAiActionPrompts === null ||
|
||||
Array.isArray(askAiActionPrompts))
|
||||
) {
|
||||
return res.status(400).json({ error: 'askAiActionPrompts must be an object' })
|
||||
}
|
||||
|
||||
const existing = await readAdminSettings()
|
||||
|
||||
// overleaf-lab: sanitize each rubric and cap the count. Entries without an id or
|
||||
// name are dropped; text fields are length-capped. When not provided, keep the
|
||||
// existing rubrics untouched.
|
||||
let sanitizedRubrics
|
||||
if (Array.isArray(complianceRubrics)) {
|
||||
sanitizedRubrics = complianceRubrics
|
||||
.map(r => ({
|
||||
id: String((r && r.id) || ''),
|
||||
name: String((r && r.name) || '').slice(0, 200),
|
||||
guidelines: String((r && r.guidelines) || '').slice(0, 20000),
|
||||
// overleaf-lab: per-rubric mechanical scans ("Label :: regex" per
|
||||
// line); policy lives with the rubric it verifies, never in code.
|
||||
scanPatterns: String((r && r.scanPatterns) || '').slice(0, 4000),
|
||||
}))
|
||||
.filter(r => r.id && r.name)
|
||||
.slice(0, 50)
|
||||
} else {
|
||||
sanitizedRubrics = Array.isArray(existing.complianceRubrics) ? existing.complianceRubrics : []
|
||||
}
|
||||
|
||||
// overleaf-lab: clamp the context window to a sane range; keep existing (or the
|
||||
// 32000 default) when not provided.
|
||||
let sanitizedMaxContextTokens
|
||||
if (maxContextTokens !== undefined) {
|
||||
const parsed = parseInt(maxContextTokens, 10)
|
||||
sanitizedMaxContextTokens = Number.isNaN(parsed)
|
||||
? existing.maxContextTokens || 32000
|
||||
: Math.min(1000000, Math.max(2000, parsed))
|
||||
} else {
|
||||
sanitizedMaxContextTokens = existing.maxContextTokens || 32000
|
||||
}
|
||||
|
||||
// overleaf-lab: clamp the review answer budget. This is the model's max_tokens for
|
||||
// the report AND the room reserved for it in the context check, so it is bounded
|
||||
// well below any real context window.
|
||||
let sanitizedReviewMaxTokens
|
||||
if (reviewMaxTokens !== undefined) {
|
||||
const parsed = parseInt(reviewMaxTokens, 10)
|
||||
sanitizedReviewMaxTokens = Number.isNaN(parsed)
|
||||
? existing.reviewMaxTokens || DEFAULT_REVIEW_MAX_TOKENS
|
||||
: Math.min(128000, Math.max(500, parsed))
|
||||
} else {
|
||||
sanitizedReviewMaxTokens = existing.reviewMaxTokens || DEFAULT_REVIEW_MAX_TOKENS
|
||||
}
|
||||
|
||||
// overleaf-lab: validate each rubric's scan patterns ("Label :: regex" per line)
|
||||
// so the admin learns about a broken regex at save time, not from a silently
|
||||
// hint-less review. The reviewer side skips invalid lines anyway (defense in
|
||||
// depth for settings written by other means).
|
||||
if (Array.isArray(complianceRubrics)) {
|
||||
for (const r of complianceRubrics) {
|
||||
const patternsText = r && typeof r.scanPatterns === 'string' ? r.scanPatterns : ''
|
||||
if (patternsText.length > 4000) {
|
||||
return res.status(400).json({
|
||||
error: `Scan patterns of rubric "${(r && r.name) || '?'}" must be 4000 characters or fewer`,
|
||||
})
|
||||
}
|
||||
for (const rawLine of patternsText.split('\n')) {
|
||||
const line = rawLine.trim()
|
||||
if (!line) {
|
||||
continue
|
||||
}
|
||||
const sep = line.indexOf('::')
|
||||
const body = (sep === -1 ? line : line.slice(sep + 2)).trim()
|
||||
if (!body) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new RegExp(body, 'i')
|
||||
} catch (err) {
|
||||
return res.status(400).json({
|
||||
error: `Invalid scan pattern regex in rubric "${(r && r.name) || '?'}": ${body}`,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// overleaf-lab: sanitize the action prompt overrides. When provided, keep only
|
||||
// known keys with string values, each capped at 4000 chars. When not provided,
|
||||
// keep the existing object untouched.
|
||||
let sanitizedActionPrompts
|
||||
if (askAiActionPrompts !== undefined) {
|
||||
sanitizedActionPrompts = {}
|
||||
for (const key of Object.keys(DEFAULT_ASK_AI_ACTION_PROMPTS)) {
|
||||
const val = askAiActionPrompts[key]
|
||||
if (typeof val === 'string') {
|
||||
sanitizedActionPrompts[key] = val.slice(0, 4000)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sanitizedActionPrompts =
|
||||
existing.askAiActionPrompts &&
|
||||
typeof existing.askAiActionPrompts === 'object' &&
|
||||
!Array.isArray(existing.askAiActionPrompts)
|
||||
? existing.askAiActionPrompts
|
||||
: {}
|
||||
}
|
||||
|
||||
const updatedSettings = {
|
||||
...existing,
|
||||
systemPrompt,
|
||||
llmApiUrl: typeof llmApiUrl === 'string' ? llmApiUrl : (existing.llmApiUrl || ''),
|
||||
allowedModels: Array.isArray(allowedModels) ? allowedModels : existing.allowedModels || [],
|
||||
completionModel: typeof completionModel === 'string' ? completionModel : (existing.completionModel || ''),
|
||||
complianceRubrics: sanitizedRubrics,
|
||||
reviewModel: typeof reviewModel === 'string' ? reviewModel : (existing.reviewModel || ''),
|
||||
maxContextTokens: sanitizedMaxContextTokens,
|
||||
reviewMaxTokens: sanitizedReviewMaxTokens,
|
||||
// overleaf-lab: omitted flag keeps the existing value (default true).
|
||||
chatEnabled: typeof chatEnabled === 'boolean' ? chatEnabled : (existing.chatEnabled !== false),
|
||||
completionEnabled: typeof completionEnabled === 'boolean' ? completionEnabled : (existing.completionEnabled !== false),
|
||||
reviewEnabled: typeof reviewEnabled === 'boolean' ? reviewEnabled : (existing.reviewEnabled !== false),
|
||||
// overleaf-lab: editable prompt overrides. An empty string is stored as-is
|
||||
// and later falls back to the default via `|| DEFAULT`.
|
||||
askAiSystemPrompt: typeof askAiSystemPrompt === 'string' ? askAiSystemPrompt : (existing.askAiSystemPrompt || ''),
|
||||
errorPrompt: typeof errorPrompt === 'string' ? errorPrompt : (existing.errorPrompt || ''),
|
||||
reviewSystemPrompt: typeof reviewSystemPrompt === 'string' ? reviewSystemPrompt : (existing.reviewSystemPrompt || ''),
|
||||
askAiActionPrompts: sanitizedActionPrompts,
|
||||
}
|
||||
|
||||
if (typeof llmApiKey === 'string' && llmApiKey.trim().length > 0) {
|
||||
updatedSettings.llmApiKey = llmApiKey.trim()
|
||||
updatedSettings.llmApiKey = encryptSecret(llmApiKey.trim()) // overleaf-lab: encrypt admin key at rest
|
||||
}
|
||||
|
||||
await writeAdminSettings(updatedSettings)
|
||||
@@ -100,12 +346,59 @@ export async function getSystemPrompt() {
|
||||
|
||||
export async function getAdminLLMSettings() {
|
||||
const settings = await readAdminSettings()
|
||||
// overleaf-lab: fall back to env so the model scan / connection-check and the
|
||||
// chat share the same effective config (mirrors buildDisplaySettings above).
|
||||
const jsonHasModels =
|
||||
Array.isArray(settings.allowedModels) && settings.allowedModels.length > 0
|
||||
// overleaf-lab: the stored admin key is encrypted at rest; decrypt before use.
|
||||
// decryptSecret returns legacy plaintext (no enc:v1: prefix) unchanged.
|
||||
const jsonKey = settings.llmApiKey ? decryptSecret(settings.llmApiKey) : ''
|
||||
return {
|
||||
llmApiUrl: settings.llmApiUrl || null,
|
||||
llmApiKey: settings.llmApiKey || null,
|
||||
allowedModels: Array.isArray(settings.allowedModels)
|
||||
? settings.allowedModels
|
||||
: [],
|
||||
llmApiUrl: settings.llmApiUrl || process.env.LLM_API_URL || null,
|
||||
llmApiKey: jsonKey || process.env.LLM_API_KEY || null,
|
||||
allowedModels: jsonHasModels ? settings.allowedModels : envModelList(),
|
||||
completionModel: settings.completionModel || '',
|
||||
// overleaf-lab: document compliance review settings
|
||||
reviewModel: settings.reviewModel || '',
|
||||
maxContextTokens: settings.maxContextTokens || 32000,
|
||||
reviewMaxTokens: settings.reviewMaxTokens || DEFAULT_REVIEW_MAX_TOKENS,
|
||||
// overleaf-lab: per-feature enable flags (absent field defaults to true).
|
||||
chatEnabled: settings.chatEnabled !== false,
|
||||
completionEnabled: settings.completionEnabled !== false,
|
||||
reviewEnabled: settings.reviewEnabled !== false,
|
||||
}
|
||||
}
|
||||
|
||||
// overleaf-lab: per-feature enable flags for the chat, inline completion, and
|
||||
// compliance review features. An absent field defaults to true so existing
|
||||
// installs keep every feature on. Used for backend enforcement across the
|
||||
// project-scoped controllers and the user settings page.
|
||||
export async function getLLMFeatureFlags() {
|
||||
const s = await readAdminSettings()
|
||||
return {
|
||||
chatEnabled: s.chatEnabled !== false,
|
||||
completionEnabled: s.completionEnabled !== false,
|
||||
reviewEnabled: s.reviewEnabled !== false,
|
||||
}
|
||||
}
|
||||
|
||||
// overleaf-lab: exposed so the compliance controller can load the configured
|
||||
// rubrics (readAdminSettings already handles the missing-file case).
|
||||
export async function getComplianceRubrics() {
|
||||
const settings = await readAdminSettings()
|
||||
return Array.isArray(settings.complianceRubrics) ? settings.complianceRubrics : []
|
||||
}
|
||||
|
||||
// overleaf-lab: resolve the EFFECTIVE editable prompts (admin override when set,
|
||||
// else the shipped default). Consumed by the compliance reviewer and the
|
||||
// project-scoped GET /llm/prompts endpoint so the frontend and backend agree.
|
||||
export async function getLLMPrompts() {
|
||||
const s = await readAdminSettings()
|
||||
return {
|
||||
askAiSystemPrompt: s.askAiSystemPrompt || DEFAULT_ASK_AI_SYSTEM_PROMPT,
|
||||
errorPrompt: s.errorPrompt || DEFAULT_ERROR_PROMPT,
|
||||
reviewSystemPrompt: s.reviewSystemPrompt || DEFAULT_REVIEW_SYSTEM_PROMPT,
|
||||
askAiActionPrompts: mergeActionPrompts(s.askAiActionPrompts),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,11 +407,17 @@ async function checkAdminLLMConnection(req, res) {
|
||||
const adminSettings = await getAdminLLMSettings()
|
||||
const effectiveUrl = apiUrl || adminSettings.llmApiUrl
|
||||
const effectiveKey = apiKey || adminSettings.llmApiKey
|
||||
// overleaf-lab: use first configured/allowed model instead of hardcoded 'qwen3-32b'
|
||||
const testModel =
|
||||
adminSettings.allowedModels[0] ||
|
||||
(process.env.LLM_MODEL_NAME || 'default').split(',')[0].trim()
|
||||
|
||||
if (!effectiveUrl || !effectiveKey) {
|
||||
// overleaf-lab: only the URL is required. A local llama.cpp server has no
|
||||
// auth, so an empty key is valid; send Authorization only when a key exists.
|
||||
if (!effectiveUrl) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'LLM API URL and API key are required',
|
||||
error: 'LLM API URL is required',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -126,14 +425,15 @@ async function checkAdminLLMConnection(req, res) {
|
||||
const timeout = setTimeout(() => controller.abort(), 30000)
|
||||
|
||||
try {
|
||||
const headers = { 'Content-Type': 'application/json' }
|
||||
if (typeof effectiveKey === 'string' && effectiveKey.length > 0) {
|
||||
headers.Authorization = `Bearer ${effectiveKey}`
|
||||
}
|
||||
const response = await fetch(`${effectiveUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${effectiveKey}`,
|
||||
},
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: 'qwen3-32b',
|
||||
model: testModel,
|
||||
messages: [{ role: 'user', content: 'Test connection' }],
|
||||
max_tokens: 1,
|
||||
}),
|
||||
@@ -172,19 +472,23 @@ async function scanAdminModels(req, res) {
|
||||
const llmApiUrl = apiUrl || adminSettings.llmApiUrl
|
||||
const llmApiKey = apiKey || adminSettings.llmApiKey
|
||||
|
||||
if (!llmApiUrl || !llmApiKey) {
|
||||
// overleaf-lab: only the URL is required. A local llama.cpp server has no
|
||||
// auth, so an empty key is valid; send Authorization only when a key exists.
|
||||
if (!llmApiUrl) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Admin LLM API URL and API key must be configured first',
|
||||
error: 'Admin LLM API URL must be configured first',
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const headers = {}
|
||||
if (typeof llmApiKey === 'string' && llmApiKey.length > 0) {
|
||||
headers.Authorization = `Bearer ${llmApiKey}`
|
||||
}
|
||||
const response = await fetch(`${llmApiUrl}/models`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${llmApiKey}`,
|
||||
},
|
||||
headers,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -4,8 +4,10 @@ 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 ProjectEntityHandler from '../../../../app/src/Features/Project/ProjectEntityHandler.mjs' // overleaf-lab: read project docs for error source context
|
||||
import Settings from '@overleaf/settings'
|
||||
import { getSystemPrompt, getAdminLLMSettings } from './LLMAdminController.mjs'
|
||||
import { getSystemPrompt, getAdminLLMSettings, getLLMFeatureFlags, getLLMPrompts } from './LLMAdminController.mjs'
|
||||
import { decryptSecret } from './LLMCrypto.mjs' // overleaf-lab: decrypt user API keys stored at rest
|
||||
|
||||
// Helper function to remove <think> tags (for DeepSeek, Qwen and similar models)
|
||||
// Handles both closed <think>...</think> and unclosed <think>... at end of string
|
||||
@@ -75,6 +77,12 @@ async function getModels(req, res) {
|
||||
return res.json({ models: [] })
|
||||
}
|
||||
|
||||
// overleaf-lab: chat feature disabled by admin -> no models to select.
|
||||
const flags = await getLLMFeatureFlags()
|
||||
if (!flags.chatEnabled) {
|
||||
return res.json({ models: [] })
|
||||
}
|
||||
|
||||
const models = []
|
||||
|
||||
// 1. Add server-wide models from admin settings/env
|
||||
@@ -93,17 +101,23 @@ async function getModels(req, res) {
|
||||
user &&
|
||||
user.useOwnLLMSettings &&
|
||||
user.llmModelName &&
|
||||
user.llmApiUrl &&
|
||||
user.llmApiKey
|
||||
user.llmApiUrl
|
||||
) {
|
||||
const personalModel = {
|
||||
id: `personal-${user.llmModelName}`,
|
||||
name: `${user.llmModelName} (🔒 Personal)`,
|
||||
isDefault: false,
|
||||
isPersonal: true,
|
||||
label: 'Private',
|
||||
// overleaf-lab: llmModelName may be a comma-separated list of
|
||||
// personal chat models; expose one selectable entry per id.
|
||||
const personalModelIds = user.llmModelName
|
||||
.split(',')
|
||||
.map(id => id.trim())
|
||||
.filter(id => id.length > 0)
|
||||
for (const modelId of personalModelIds) {
|
||||
models.push({
|
||||
id: `personal-${modelId}`,
|
||||
name: `${modelId} (🔒 Personal)`,
|
||||
isDefault: false,
|
||||
isPersonal: true,
|
||||
label: 'Private',
|
||||
})
|
||||
}
|
||||
models.push(personalModel)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
@@ -154,13 +168,27 @@ async function chat(req, res) {
|
||||
return res.status(503).json({ error: 'LLM service is disabled' })
|
||||
}
|
||||
|
||||
// overleaf-lab: chat feature disabled by admin. Enforced for everyone, including
|
||||
// users with personal API keys, before any personal-settings resolution.
|
||||
const flags = await getLLMFeatureFlags()
|
||||
if (!flags.chatEnabled) {
|
||||
return res.status(403).json({ error: 'feature_disabled', message: 'The chat feature is disabled' })
|
||||
}
|
||||
|
||||
const isPersonalModel = model && model.startsWith('personal-')
|
||||
// overleaf-lab: when the request carries NO model (the selection toolbar /
|
||||
// "Ask AI" sends only messages), honor the user's personal LLM settings if they
|
||||
// have them, so those features use the user's own key/model just like the chat
|
||||
// does. Falls back to the shared backend when no personal settings exist.
|
||||
const tryPersonalNoModel =
|
||||
!model && userId && Settings.llm && Settings.llm.allowUserSettings
|
||||
|
||||
const adminLlmSettings = await getAdminLLMSettings()
|
||||
let llmApiUrl = adminLlmSettings.llmApiUrl || process.env.LLM_API_URL
|
||||
let llmApiKey = adminLlmSettings.llmApiKey || process.env.LLM_API_KEY
|
||||
let personalModelName = null
|
||||
|
||||
if (isPersonalModel && userId) {
|
||||
if ((isPersonalModel || tryPersonalNoModel) && userId) {
|
||||
try {
|
||||
const user = await User.findById(
|
||||
userId,
|
||||
@@ -170,37 +198,40 @@ async function chat(req, res) {
|
||||
user &&
|
||||
user.useOwnLLMSettings &&
|
||||
user.llmApiUrl &&
|
||||
user.llmApiKey
|
||||
(isPersonalModel || user.llmModelName)
|
||||
) {
|
||||
llmApiUrl = user.llmApiUrl
|
||||
llmApiKey = user.llmApiKey
|
||||
} else {
|
||||
llmApiKey = user.llmApiKey ? decryptSecret(user.llmApiKey) : '' // overleaf-lab: decrypt stored key at rest (empty when keyless)
|
||||
personalModelName = isPersonalModel
|
||||
? model.substring('personal-'.length)
|
||||
: user.llmModelName.split(',')[0].trim() // overleaf-lab: no model sent -> use the user's first (default) model
|
||||
} else if (isPersonalModel) {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
'Your LLM settings are incomplete. Please configure API URL, API Key, and Model Name in your account settings.',
|
||||
})
|
||||
}
|
||||
// A no-model request with incomplete personal settings falls through to
|
||||
// the shared backend checked below.
|
||||
} 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 (isPersonalModel) {
|
||||
return res.status(500).json({
|
||||
error: 'Failed to retrieve user LLM settings',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!llmApiUrl || !llmApiKey) {
|
||||
return res.status(503).json({ error: 'LLM service is not configured' })
|
||||
if (!llmApiUrl) {
|
||||
return res.status(503).json({
|
||||
error:
|
||||
'LLM service is not configured. Please contact your administrator or configure your own LLM settings.',
|
||||
})
|
||||
}
|
||||
|
||||
const modelNameForApi = isPersonalModel && model
|
||||
? model.substring('personal-'.length)
|
||||
: model || 'qwen3-32b'
|
||||
const modelNameForApi = personalModelName
|
||||
? personalModelName
|
||||
: model || ((process.env.LLM_MODEL_NAME || process.env.LLM_AVAILABLE_MODELS || 'default').split(',')[0].trim()) // overleaf-lab: env-configurable fallback instead of hardcoded 'qwen3-32b'
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => {
|
||||
@@ -210,23 +241,22 @@ async function chat(req, res) {
|
||||
try {
|
||||
const llmApiFullUrl = `${llmApiUrl}/chat/completions`
|
||||
|
||||
// Prepend admin-configured system prompt if set and not already present
|
||||
let finalMessages = messages
|
||||
// Prepend the admin-configured system prompt (if any) plus an always-on
|
||||
// instruction to reply in the user's language, then merge any client
|
||||
// system message. This keeps replies in the input language regardless of
|
||||
// whether an admin prompt is set.
|
||||
const languageInstruction = `Reply in the same language as the user's latest message (for example, answer in Italian if the user writes in Italian).`
|
||||
const 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 systemPreamble = adminSystemPrompt
|
||||
? `${adminSystemPrompt}\n\n${languageInstruction}`
|
||||
: languageInstruction
|
||||
const hasSystemMessage = messages.length > 0 && messages[0].role === 'system'
|
||||
const finalMessages = hasSystemMessage
|
||||
? [
|
||||
{ role: 'system', content: `${systemPreamble}\n\n${messages[0].content}` },
|
||||
...messages.slice(1),
|
||||
]
|
||||
: [{ role: 'system', content: systemPreamble }, ...messages]
|
||||
|
||||
const requestBody = {
|
||||
model: modelNameForApi,
|
||||
@@ -248,12 +278,16 @@ async function chat(req, res) {
|
||||
'[LLM] chat: sending request to LLM API'
|
||||
)
|
||||
|
||||
// overleaf-lab: send Authorization only when a non-empty key exists, so a
|
||||
// keyless local server is not sent a malformed empty Bearer header.
|
||||
const chatHeaders = { 'Content-Type': 'application/json' }
|
||||
if (typeof llmApiKey === 'string' && llmApiKey.length > 0) {
|
||||
chatHeaders.Authorization = `Bearer ${llmApiKey}`
|
||||
}
|
||||
|
||||
const response = await fetch(llmApiFullUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${llmApiKey}`,
|
||||
},
|
||||
headers: chatHeaders,
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: controller.signal,
|
||||
})
|
||||
@@ -360,33 +394,96 @@ async function completion(req, res) {
|
||||
return res.status(503).json({ success: false, error: 'LLM service is disabled' })
|
||||
}
|
||||
|
||||
// overleaf-lab: inline completion disabled by admin. Enforced before any
|
||||
// personal-settings resolution so a personal key cannot re-enable completion.
|
||||
// Return an empty suggestion so the editor simply shows nothing, with no error.
|
||||
const flags = await getLLMFeatureFlags()
|
||||
if (!flags.completionEnabled) {
|
||||
return res.json({ success: true, data: '' })
|
||||
}
|
||||
|
||||
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) {
|
||||
// overleaf-lab: the admin can disable shared inline completion (a sentinel value
|
||||
// for the completion model). When disabled, only users with their own completion
|
||||
// route get suggestions; everyone else gets none. This keeps a self-hosted CPU
|
||||
// backend free from the high-frequency autocomplete load.
|
||||
const sharedCompletionDisabled = adminLlmSettings.completionModel === '__disabled__'
|
||||
|
||||
// overleaf-lab: default completion model for the shared backend. Prefer the
|
||||
// admin-chosen completion model, then the env override, then the first env
|
||||
// model. May be overridden below by the user's personal completion settings.
|
||||
let completionModel =
|
||||
adminLlmSettings.completionModel ||
|
||||
process.env.LLM_COMPLETION_MODEL ||
|
||||
(process.env.LLM_MODEL_NAME || 'default').split(',')[0].trim() // 'default' instead of hardcoded 'qwen3-32b'
|
||||
|
||||
// overleaf-lab: per-user inline-completion override (highest precedence). When
|
||||
// the user opted into their own provider AND explicitly picked a completion
|
||||
// model, route completion to their personal endpoint+key+model, overriding the
|
||||
// shared/local server. Incomplete personal creds -> silently fall back to shared.
|
||||
let usingPersonalCompletion = false
|
||||
if (userId) {
|
||||
try {
|
||||
const user = await User.findById(
|
||||
userId,
|
||||
'useOwnLLMSettings llmApiUrl llmApiKey llmCompletionModel'
|
||||
)
|
||||
if (
|
||||
user &&
|
||||
user.useOwnLLMSettings &&
|
||||
user.llmCompletionModel &&
|
||||
user.llmApiUrl &&
|
||||
user.llmApiKey
|
||||
) {
|
||||
llmApiUrl = user.llmApiUrl
|
||||
llmApiKey = decryptSecret(user.llmApiKey) // decrypt stored key at rest
|
||||
completionModel = user.llmCompletionModel
|
||||
usingPersonalCompletion = true
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn({ userId, err: error }, '[LLM] Error loading user completion settings')
|
||||
}
|
||||
}
|
||||
|
||||
// Try the user's own settings when NO shared server URL is configured, OR when
|
||||
// the admin disabled shared completion (so a user with their own API still gets
|
||||
// suggestions from their own endpoint).
|
||||
// overleaf-lab: gate on the URL alone (not the key). A keyless shared endpoint
|
||||
// is valid, so an empty key must NOT hijack completion onto the user's personal
|
||||
// endpoint while the shared one is fine. When we do fall back, also switch
|
||||
// completionModel to the user's own model, otherwise the env-derived 'default'
|
||||
// is sent to their endpoint (e.g. OpenAI 404s on model 'default').
|
||||
if (!usingPersonalCompletion && (!llmApiUrl || sharedCompletionDisabled) && userId) {
|
||||
try {
|
||||
const user = await User.findById(
|
||||
userId,
|
||||
'useOwnLLMSettings llmApiUrl llmApiKey llmModelName'
|
||||
)
|
||||
if (user && user.useOwnLLMSettings && user.llmApiUrl && user.llmApiKey) {
|
||||
if (user && user.useOwnLLMSettings && user.llmApiUrl) {
|
||||
llmApiUrl = user.llmApiUrl
|
||||
llmApiKey = user.llmApiKey
|
||||
llmApiKey = user.llmApiKey ? decryptSecret(user.llmApiKey) : '' // decrypt stored key at rest (empty when keyless)
|
||||
if (user.llmModelName) {
|
||||
completionModel = user.llmModelName.split(',')[0].trim()
|
||||
}
|
||||
}
|
||||
} 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' })
|
||||
// overleaf-lab: shared completion disabled and no personal route resolved (the
|
||||
// completion model is still the sentinel) -> return an empty suggestion so the
|
||||
// editor simply shows nothing, with no error.
|
||||
if (completionModel === '__disabled__') {
|
||||
return res.json({ success: true, data: '' })
|
||||
}
|
||||
|
||||
const completionModel =
|
||||
process.env.LLM_COMPLETION_MODEL ||
|
||||
(process.env.LLM_MODEL_NAME || 'qwen3-32b').split(',')[0].trim()
|
||||
if (!llmApiUrl) {
|
||||
return res.status(503).json({ success: false, error: 'LLM service is not configured' })
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => {
|
||||
@@ -394,18 +491,21 @@ async function completion(req, res) {
|
||||
}, 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 systemPrompt = `/no_think\nYou are a text completion engine. Output ONLY the missing text, in the same language as the surrounding text. No thinking, no explanation, no markdown, no code fences, no tags. Just the raw continuation characters.`
|
||||
|
||||
const userPrompt = `Complete the text at [CURSOR]. Output only the few words that replace [CURSOR]:
|
||||
|
||||
${leftContext}[CURSOR]${rightContext}`
|
||||
|
||||
// overleaf-lab: send Authorization only when a non-empty key exists.
|
||||
const completionHeaders = { 'Content-Type': 'application/json' }
|
||||
if (typeof llmApiKey === 'string' && llmApiKey.length > 0) {
|
||||
completionHeaders.Authorization = `Bearer ${llmApiKey}`
|
||||
}
|
||||
|
||||
const response = await fetch(`${llmApiUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${llmApiKey}`,
|
||||
},
|
||||
headers: completionHeaders,
|
||||
body: JSON.stringify({
|
||||
model: completionModel,
|
||||
messages: [
|
||||
@@ -453,8 +553,111 @@ ${leftContext}[CURSOR]${rightContext}`
|
||||
}
|
||||
}
|
||||
|
||||
// overleaf-lab: expose the per-feature enable flags to the project UI so it can hide
|
||||
// disabled features. allowUserSettings tells the client whether personal settings
|
||||
// are available at all.
|
||||
async function getFeatures(req, res) {
|
||||
const flags = await getLLMFeatureFlags()
|
||||
res.json({ ...flags, allowUserSettings: !!(Settings.llm && Settings.llm.allowUserSettings) })
|
||||
}
|
||||
|
||||
// overleaf-lab: return a window of source lines around a compile-error line, so the
|
||||
// "Ask AI about this error" prompt can include the actual code (not just the log
|
||||
// message). Works for any project file via getAllDocs, so an error inside an
|
||||
// \input-ed file that is not open in the editor is still covered.
|
||||
async function getSourceContext(req, res) {
|
||||
const flags = await getLLMFeatureFlags()
|
||||
if (!flags.chatEnabled) {
|
||||
return res.json({ ok: false, error: 'feature_disabled' })
|
||||
}
|
||||
|
||||
const projectId = req.params.Project_id
|
||||
const rawFile = String(req.query.file || '')
|
||||
const line = parseInt(req.query.line, 10)
|
||||
let radius = parseInt(req.query.radius, 10)
|
||||
if (!Number.isFinite(radius) || radius < 0) {
|
||||
radius = 15
|
||||
}
|
||||
radius = Math.min(radius, 40) // overleaf-lab: cap the snippet size
|
||||
|
||||
if (!rawFile || !Number.isFinite(line) || line < 1) {
|
||||
return res.json({ ok: false, error: 'bad_request' })
|
||||
}
|
||||
|
||||
// overleaf-lab: normalize a log file path (may be /compile/x, ./x, or /x) to the
|
||||
// project doc path key used by getAllDocs.
|
||||
const norm = p =>
|
||||
String(p || '')
|
||||
.replace(/^\/?compile\//, '')
|
||||
.replace(/^\.\//, '')
|
||||
.replace(/^\//, '')
|
||||
|
||||
try {
|
||||
const docsByPath = await ProjectEntityHandler.promises.getAllDocs(projectId)
|
||||
const target = norm(rawFile)
|
||||
const targetBase = target.split('/').pop()
|
||||
let match = null
|
||||
let baseMatch = null
|
||||
for (const [docPath, value] of Object.entries(docsByPath || {})) {
|
||||
if (!value) {
|
||||
continue
|
||||
}
|
||||
const np = norm(docPath)
|
||||
if (np === target || np.endsWith('/' + target) || target.endsWith('/' + np)) {
|
||||
match = { path: docPath, lines: value.lines || [] }
|
||||
break
|
||||
}
|
||||
if (!baseMatch && np.split('/').pop() === targetBase) {
|
||||
baseMatch = { path: docPath, lines: value.lines || [] }
|
||||
}
|
||||
}
|
||||
match = match || baseMatch
|
||||
if (!match) {
|
||||
return res.json({ ok: false, error: 'not_found' })
|
||||
}
|
||||
|
||||
const lines = match.lines
|
||||
const idx = line - 1
|
||||
const start = Math.max(0, idx - radius)
|
||||
const end = Math.min(lines.length, idx + radius + 1)
|
||||
const numbered = []
|
||||
for (let i = start; i < end; i++) {
|
||||
// overleaf-lab: a leading '>' marks the line the compiler flagged.
|
||||
const marker = i === idx ? '>' : ' '
|
||||
numbered.push(`${marker} ${i + 1}: ${lines[i]}`)
|
||||
}
|
||||
|
||||
return res.json({
|
||||
ok: true,
|
||||
file: match.path,
|
||||
line,
|
||||
startLine: start + 1,
|
||||
snippet: numbered.join('\n'),
|
||||
})
|
||||
} catch (err) {
|
||||
logger.warn({ projectId, err }, '[LLM] source-context failed')
|
||||
return res.json({ ok: false, error: 'failed' })
|
||||
}
|
||||
}
|
||||
|
||||
// overleaf-lab: expose the EFFECTIVE editable prompts (admin override or default) to
|
||||
// the project UI so the "Ask AI" toolbar and the "Ask AI about this error" button use
|
||||
// the admin-tuned system prompt, action templates, and error instruction block. The
|
||||
// review system prompt stays server-side and is not returned here.
|
||||
async function getPrompts(req, res) {
|
||||
const prompts = await getLLMPrompts()
|
||||
res.json({
|
||||
askAiSystemPrompt: prompts.askAiSystemPrompt,
|
||||
errorPrompt: prompts.errorPrompt,
|
||||
askAiActionPrompts: prompts.askAiActionPrompts,
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
chat: expressify(chat),
|
||||
getModels: expressify(getModels),
|
||||
completion: expressify(completion),
|
||||
getFeatures: expressify(getFeatures),
|
||||
getSourceContext: expressify(getSourceContext),
|
||||
getPrompts: expressify(getPrompts),
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,89 @@
|
||||
import crypto from 'node:crypto'
|
||||
import logger from '@overleaf/logger'
|
||||
|
||||
// overleaf-lab: at-rest encryption for per-user LLM API keys (AES-256-GCM).
|
||||
// The key is derived from LLM_KEY_SECRET (auto-generated & persisted by
|
||||
// configure.sh). Values are stored as `enc:v1:<iv>:<tag>:<ciphertext>` (base64).
|
||||
// Legacy plaintext values (no `enc:v1:` prefix) are read back unchanged, and a
|
||||
// missing LLM_KEY_SECRET degrades gracefully (keys stored/read as plaintext)
|
||||
// instead of crashing.
|
||||
|
||||
const ENC_PREFIX = 'enc:v1:'
|
||||
const KEK_SALT = 'overleaf-lab-llm-key-v1'
|
||||
const IV_BYTES = 12
|
||||
|
||||
// Guard so we only warn once about the missing secret (avoids log spam).
|
||||
let warnedMissingSecret = false
|
||||
|
||||
function deriveKey(secret) {
|
||||
return crypto.scryptSync(secret, KEK_SALT, 32)
|
||||
}
|
||||
|
||||
export function encryptSecret(plain) {
|
||||
if (!plain) {
|
||||
return plain
|
||||
}
|
||||
|
||||
const secret = process.env.LLM_KEY_SECRET
|
||||
if (!secret) {
|
||||
if (!warnedMissingSecret) {
|
||||
warnedMissingSecret = true
|
||||
logger.warn(
|
||||
{},
|
||||
'[LLM] LLM_KEY_SECRET is not set: per-user API keys are stored UNENCRYPTED at rest'
|
||||
)
|
||||
}
|
||||
return plain
|
||||
}
|
||||
|
||||
const key = deriveKey(secret)
|
||||
const iv = crypto.randomBytes(IV_BYTES)
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv)
|
||||
const ct = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()])
|
||||
const tag = cipher.getAuthTag()
|
||||
|
||||
return `${ENC_PREFIX}${iv.toString('base64')}:${tag.toString('base64')}:${ct.toString('base64')}`
|
||||
}
|
||||
|
||||
export function decryptSecret(stored) {
|
||||
if (!stored) {
|
||||
return stored
|
||||
}
|
||||
|
||||
// Legacy plaintext back-compat: values written before encryption was added
|
||||
// (or while LLM_KEY_SECRET was unset) have no prefix and are returned as-is.
|
||||
if (!stored.startsWith(ENC_PREFIX)) {
|
||||
return stored
|
||||
}
|
||||
|
||||
const secret = process.env.LLM_KEY_SECRET
|
||||
if (!secret) {
|
||||
logger.error(
|
||||
{},
|
||||
'[LLM] Cannot decrypt stored API key: value is encrypted but LLM_KEY_SECRET is not set'
|
||||
)
|
||||
return ''
|
||||
}
|
||||
|
||||
try {
|
||||
const [ivB64, tagB64, ctB64] = stored.slice(ENC_PREFIX.length).split(':')
|
||||
if (!ivB64 || !tagB64 || !ctB64) {
|
||||
throw new Error('malformed encrypted value')
|
||||
}
|
||||
|
||||
const key = deriveKey(secret)
|
||||
const iv = Buffer.from(ivB64, 'base64')
|
||||
const tag = Buffer.from(tagB64, 'base64')
|
||||
const ct = Buffer.from(ctB64, 'base64')
|
||||
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv)
|
||||
decipher.setAuthTag(tag)
|
||||
const plain = Buffer.concat([decipher.update(ct), decipher.final()])
|
||||
return plain.toString('utf8')
|
||||
} catch (err) {
|
||||
// Wrong key (rotated/lost secret) or tampering: fail closed so chat
|
||||
// reports "not configured" rather than crashing the request.
|
||||
logger.error({ err }, '[LLM] Failed to decrypt stored API key')
|
||||
return ''
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// overleaf-lab: canonical default prompts for the LLM module. These are the exact
|
||||
// strings that used to be hardcoded in the frontend toolbar, the "Ask AI about
|
||||
// this error" button, and the compliance reviewer. They now live here as the
|
||||
// single source of truth so a super-admin can override them (see
|
||||
// LLMAdminController) while the effective value always falls back to these
|
||||
// defaults. Keep these byte-identical to the shipped behavior; the only change
|
||||
// from the frontend action templates is that the `${selectionText}` interpolation
|
||||
// was replaced with the literal `{{selection}}` placeholder the UI substitutes.
|
||||
|
||||
// overleaf-lab: the floating "Ask AI" selection toolbar system prompt.
|
||||
export const DEFAULT_ASK_AI_SYSTEM_PROMPT = `You are a LaTeX writing assistant embedded in an editor. Preserve existing LaTeX commands, math, and citation keys exactly, and reply in the same language as the input. When asked to rewrite or transform text, return only the resulting text, with no preamble and no Markdown code fences.`
|
||||
|
||||
// overleaf-lab: trailing instruction block appended to a compile error before it is
|
||||
// sent to the chat by the "Ask AI about this error" button.
|
||||
export const DEFAULT_ERROR_PROMPT = `**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`
|
||||
|
||||
// overleaf-lab: system prompt for the document compliance reviewer.
|
||||
export const DEFAULT_REVIEW_SYSTEM_PROMPT = `You are a meticulous reviewer that checks whether a LaTeX document complies with writing guidelines for academic theses and internship reports.
|
||||
|
||||
You will receive:
|
||||
1. DOCUMENT: the full LaTeX source of the project, split into files, each introduced by a line "% ===== FILE: <path> =====".
|
||||
2. Possibly SCAN HINTS: mechanical pattern-scan results computed in code from the source, exhaustive for their listed patterns. TRUST their counts and their "none found" statements (they beat your own reading for those patterns). The listed candidates over-capture on purpose: judge each one in context before counting it as a violation.
|
||||
3. GUIDELINES: the requirement(s) to check in THIS pass. Judge ONLY these requirements; every other aspect of the document is out of scope here.
|
||||
|
||||
Be strict and skeptical. "ok" means you actually verified the requirement, not that you found related-looking text. Use the "analysis" field as your worksheet, BEFORE judging: when a requirement covers every figure, table or citation, walk through them there one by one (a compact enumeration in "analysis" is encouraged: writing it out is how you verify). When nothing is wrong a count suffices; enumerate when you are checking item by item. For a requirement asserting an ABSENCE (nothing of some kind exists), state what you scanned and how completely. If you could not verify exhaustively, say so and use "partial" instead of "ok". Keep "evidence" compact regardless: it is the part the user reads.
|
||||
|
||||
Evidence rules:
|
||||
- The evidence must actually support the verdict: quote text that CONTAINS the thing you are judging, with the file path from the nearest "FILE:" header. Never quote unrelated text just to fill the field.
|
||||
- For a requirement that is not satisfied, quote the offending text; if it occurs in several places, list up to five, separated by " | ".
|
||||
- A quote cannot prove an absence: for absence requirements the evidence must describe the scan (for example "scanned all 31 entries in references.bib, none points to Wikipedia").
|
||||
- Report counts plus at most five short examples, never a full enumeration. Keep each item's evidence under about 500 characters: it is the part the user reads, and pages of pasted source make the report unreadable.
|
||||
- NEVER mention line numbers or equation numbers: the source you receive has neither, so any you produce would be invented. Locate only by file path and verbatim quote.
|
||||
- For "na", state briefly why it cannot be verified from the source.
|
||||
|
||||
Reply in the same language as the GUIDELINES (for example, in Italian if the guidelines are in Italian). This includes the "suggestion" field.
|
||||
|
||||
Return ONLY a JSON object, with no preamble, no explanation, and no code fences, in exactly this shape:
|
||||
{
|
||||
"items": [
|
||||
{ "analysis": "what you scanned and what you found, written before judging", "requirement": "the guideline requirement, restated concisely", "status": "ok", "evidence": "file path and verbatim quote(s), or the description of the scan", "suggestion": "a concrete suggestion to satisfy it (empty string when status is ok)" }
|
||||
]
|
||||
}
|
||||
Use "ok" when clearly satisfied, "partial" when partially satisfied or only partially verified, "missing" when not satisfied, "na" when not applicable or impossible to verify from the source.`
|
||||
|
||||
// overleaf-lab: per-action templates for the "Ask AI" selection toolbar. Each
|
||||
// template embeds the selected text where the `{{selection}}` placeholder appears;
|
||||
// the frontend substitutes it before sending. Keys map to the toolbar modes:
|
||||
// 1=paraphrase, 2=academic, 3=concise, 4=punchy, 5=split, 6=join, 7=summarize,
|
||||
// 8=explain, 9=title, 10=abstract.
|
||||
export const DEFAULT_ASK_AI_ACTION_PROMPTS = {
|
||||
paraphrase: `Paraphrase the following LaTeX text. Keep every LaTeX command, math, and citation key intact. Output only the paraphrased text, with no preamble, no explanation, and no code fences.\n\n{{selection}}`,
|
||||
academic: `Rewrite the following LaTeX text in fluent, formal academic English. Preserve every LaTeX command, math, and citation key. Output only the rewritten text, with no preamble and no code fences.\n\n{{selection}}`,
|
||||
concise: `Rewrite the following LaTeX text more concisely, preserving its meaning and every LaTeX command, math, and citation. Output only the rewritten text, nothing else.\n\n{{selection}}`,
|
||||
punchy: `Rewrite the following LaTeX text in a punchier, more engaging style while keeping it accurate. Preserve every LaTeX command, math, and citation. Output only the rewritten text, nothing else.\n\n{{selection}}`,
|
||||
split: `Split the following LaTeX paragraph into several shorter, well-structured paragraphs. Keep the wording and all LaTeX; only add paragraph breaks. Output only the resulting LaTeX, nothing else.\n\n{{selection}}`,
|
||||
join: `Join the following LaTeX paragraphs into a single cohesive paragraph, preserving every LaTeX command, math, and citation. Output only the resulting paragraph, nothing else.\n\n{{selection}}`,
|
||||
summarize: `Summarize the following LaTeX text concisely. Output only the summary as plain LaTeX, with no preamble and no code fences.\n\n{{selection}}`,
|
||||
explain: `Explain the following LaTeX text clearly and concisely for the author:\n\n{{selection}}`,
|
||||
title: `Propose one concise, specific academic title for the following content. Output only the title text: no quotes, no label, no trailing period.\n\n{{selection}}`,
|
||||
abstract: `Write a single self-contained academic abstract (about 150 to 250 words) for the following content. Output only the abstract text: no heading, no label, and no code fences.\n\n{{selection}}`,
|
||||
}
|
||||
|
||||
// overleaf-lab: return the default action prompts with any valid string overrides
|
||||
// from `stored` applied per key. `stored` is expected to be an object; unknown keys
|
||||
// are ignored and non-string values fall back to the default for that key.
|
||||
export function mergeActionPrompts(stored) {
|
||||
const merged = { ...DEFAULT_ASK_AI_ACTION_PROMPTS }
|
||||
if (stored && typeof stored === 'object' && !Array.isArray(stored)) {
|
||||
for (const key of Object.keys(DEFAULT_ASK_AI_ACTION_PROMPTS)) {
|
||||
if (typeof stored[key] === 'string') {
|
||||
merged[key] = stored[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import Settings from '@overleaf/settings'
|
||||
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'
|
||||
import LLMComplianceController from './LLMComplianceController.mjs'
|
||||
|
||||
export default {
|
||||
apply(webRouter) {
|
||||
@@ -33,6 +34,31 @@ export default {
|
||||
)
|
||||
logger.debug({}, '[LLM] Route registered: GET /project/:id/llm/models')
|
||||
|
||||
// overleaf-lab: per-feature enable flags for the project UI.
|
||||
webRouter.get(
|
||||
'/project/:Project_id/llm/features',
|
||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||
LLMChatController.getFeatures
|
||||
)
|
||||
logger.debug({}, '[LLM] Route registered: GET /project/:id/llm/features')
|
||||
|
||||
// overleaf-lab: source lines around a compile-error line for "Ask AI about this error"
|
||||
webRouter.get(
|
||||
'/project/:Project_id/llm/source-context',
|
||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||
LLMChatController.getSourceContext
|
||||
)
|
||||
logger.debug({}, '[LLM] Route registered: GET /project/:id/llm/source-context')
|
||||
|
||||
// overleaf-lab: effective editable prompts (Ask AI system prompt, error
|
||||
// instruction block, and per-action templates) for the project UI.
|
||||
webRouter.get(
|
||||
'/project/:Project_id/llm/prompts',
|
||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||
LLMChatController.getPrompts
|
||||
)
|
||||
logger.debug({}, '[LLM] Route registered: GET /project/:id/llm/prompts')
|
||||
|
||||
// Inline completion endpoint (project-scoped)
|
||||
webRouter.post(
|
||||
'/project/:Project_id/llm/completion',
|
||||
@@ -41,6 +67,35 @@ export default {
|
||||
)
|
||||
logger.debug({}, '[LLM] Route registered: POST /project/:id/llm/completion')
|
||||
|
||||
// overleaf-lab: document compliance review endpoints (project-scoped)
|
||||
webRouter.get(
|
||||
'/project/:Project_id/llm/compliance/rubrics',
|
||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||
LLMComplianceController.getRubrics
|
||||
)
|
||||
logger.debug({}, '[LLM] Route registered: GET /project/:id/llm/compliance/rubrics')
|
||||
|
||||
webRouter.post(
|
||||
'/project/:Project_id/llm/compliance/start',
|
||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||
LLMComplianceController.startReview
|
||||
)
|
||||
logger.debug({}, '[LLM] Route registered: POST /project/:id/llm/compliance/start')
|
||||
|
||||
webRouter.get(
|
||||
'/project/:Project_id/llm/compliance/status/:jobId',
|
||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||
LLMComplianceController.statusReview
|
||||
)
|
||||
logger.debug({}, '[LLM] Route registered: GET /project/:id/llm/compliance/status/:jobId')
|
||||
|
||||
webRouter.post(
|
||||
'/project/:Project_id/llm/compliance/cancel/:jobId',
|
||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||
LLMComplianceController.cancelReview
|
||||
)
|
||||
logger.debug({}, '[LLM] Route registered: POST /project/:id/llm/compliance/cancel/:jobId')
|
||||
|
||||
// User LLM settings (only if allowed)
|
||||
if (Settings.llm && Settings.llm.allowUserSettings) {
|
||||
webRouter.get(
|
||||
@@ -63,6 +118,13 @@ export default {
|
||||
)
|
||||
logger.debug({}, '[LLM] Route registered: POST /user/llm-settings/check')
|
||||
|
||||
webRouter.post(
|
||||
'/user/llm-settings/models',
|
||||
AuthenticationController.requireLogin(),
|
||||
LLMSettingsController.scanUserModels
|
||||
)
|
||||
logger.debug({}, '[LLM] Route registered: POST /user/llm-settings/models')
|
||||
|
||||
webRouter.post(
|
||||
'/user/llm-settings',
|
||||
AuthenticationController.requireLogin(),
|
||||
|
||||
@@ -5,6 +5,8 @@ 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'
|
||||
import { encryptSecret, decryptSecret } from './LLMCrypto.mjs' // overleaf-lab: at-rest encryption of user API keys
|
||||
import { getLLMFeatureFlags } from './LLMAdminController.mjs' // overleaf-lab: per-feature enable flags
|
||||
|
||||
const llmSettingsPugPath = fileURLToPath(
|
||||
new URL('../../app/views/llm-settings.pug', import.meta.url)
|
||||
@@ -15,11 +17,18 @@ async function llmSettingsPage(req, res) {
|
||||
|
||||
logger.debug({ userId, pugPath: llmSettingsPugPath }, '[LLM] llmSettingsPage: rendering')
|
||||
|
||||
// overleaf-lab: when both chat and inline completion are disabled by the admin,
|
||||
// the personal-settings page has nothing to configure, so send the user home.
|
||||
const flags = await getLLMFeatureFlags()
|
||||
if (!flags.chatEnabled && !flags.completionEnabled) {
|
||||
return res.redirect('/')
|
||||
}
|
||||
|
||||
let user = {}
|
||||
try {
|
||||
user = await User.findById(
|
||||
userId,
|
||||
'useOwnLLMSettings llmModelName llmApiUrl llmApiKey'
|
||||
'useOwnLLMSettings llmModelName llmApiUrl llmApiKey llmCompletionModel'
|
||||
)
|
||||
} catch (err) {
|
||||
logger.warn({ userId, err }, '[LLM] Error loading user for settings page')
|
||||
@@ -30,6 +39,7 @@ async function llmSettingsPage(req, res) {
|
||||
modelName: user?.llmModelName || '',
|
||||
apiUrl: user?.llmApiUrl || '',
|
||||
hasApiKey: !!(user?.llmApiKey),
|
||||
completionModel: user?.llmCompletionModel || '', // overleaf-lab: per-user inline-completion model
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
@@ -37,7 +47,11 @@ async function llmSettingsPage(req, res) {
|
||||
'[LLM] llmSettingsPage: user settings loaded'
|
||||
)
|
||||
|
||||
res.render(llmSettingsPugPath, { user: { llmSettings } })
|
||||
res.render(llmSettingsPugPath, {
|
||||
user: { llmSettings },
|
||||
// overleaf-lab: expose the enable flags so the page can hide the disabled section.
|
||||
featureFlags: { chatEnabled: flags.chatEnabled, completionEnabled: flags.completionEnabled },
|
||||
})
|
||||
}
|
||||
|
||||
async function checkLLMConnection(req, res) {
|
||||
@@ -55,7 +69,7 @@ async function checkLLMConnection(req, res) {
|
||||
try {
|
||||
const user = await User.findById(userId, 'llmApiKey')
|
||||
if (user && user.llmApiKey) {
|
||||
apiKey = user.llmApiKey
|
||||
apiKey = decryptSecret(user.llmApiKey) // overleaf-lab: decrypt stored key at rest
|
||||
logger.debug({ userId }, '[LLM] checkLLMConnection: using stored API key')
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -63,7 +77,9 @@ async function checkLLMConnection(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!apiUrl || !apiKey || !modelName) {
|
||||
// overleaf-lab: the key is optional (a local llama.cpp server has no auth);
|
||||
// only the URL and model name are required.
|
||||
if (!apiUrl || !modelName) {
|
||||
return res.status(400).json({ error: 'Missing required parameters' })
|
||||
}
|
||||
|
||||
@@ -84,12 +100,14 @@ async function checkLLMConnection(req, res) {
|
||||
|
||||
const startTime = Date.now()
|
||||
|
||||
const headers = { 'Content-Type': 'application/json' }
|
||||
if (typeof apiKey === 'string' && apiKey.length > 0) {
|
||||
headers.Authorization = `Bearer ${apiKey}`
|
||||
}
|
||||
|
||||
const response = await fetch(llmApiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
headers,
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: controller.signal,
|
||||
})
|
||||
@@ -136,9 +154,97 @@ async function checkLLMConnection(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
// overleaf-lab: scan the user's own LLM provider for available model ids.
|
||||
// Mirrors LLMAdminController.scanAdminModels but resolves credentials from the
|
||||
// request body, falling back to the user's stored key/URL (like checkLLMConnection).
|
||||
async function scanUserModels(req, res) {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
const { apiUrl: providedApiUrl, apiKey: providedApiKey } = req.body
|
||||
|
||||
logger.debug(
|
||||
{ userId, apiUrl: providedApiUrl, hasProvidedKey: !!providedApiKey },
|
||||
'[LLM] scanUserModels: request received'
|
||||
)
|
||||
|
||||
let apiKey = providedApiKey
|
||||
let apiUrl = providedApiUrl
|
||||
// Fall back to stored key/URL when either is omitted
|
||||
if (!apiKey || !apiUrl) {
|
||||
try {
|
||||
const user = await User.findById(userId, 'llmApiKey llmApiUrl')
|
||||
if (user) {
|
||||
if (!apiKey && user.llmApiKey) {
|
||||
apiKey = decryptSecret(user.llmApiKey) // overleaf-lab: decrypt stored key at rest
|
||||
}
|
||||
if (!apiUrl && user.llmApiUrl) {
|
||||
apiUrl = user.llmApiUrl
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn({ userId, err }, '[LLM] Could not fetch stored settings for model scan')
|
||||
}
|
||||
}
|
||||
|
||||
// overleaf-lab: only the URL is required. A local llama.cpp server has no
|
||||
// auth, so an empty key is valid; send Authorization only when a key exists.
|
||||
if (!apiUrl) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'API URL is required',
|
||||
})
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 30000)
|
||||
|
||||
try {
|
||||
const headers = {}
|
||||
if (typeof apiKey === 'string' && apiKey.length > 0) {
|
||||
headers.Authorization = `Bearer ${apiKey}`
|
||||
}
|
||||
const response = await fetch(`${apiUrl}/models`, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
clearTimeout(timeout)
|
||||
|
||||
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)).sort()
|
||||
: []
|
||||
|
||||
res.json({ success: true, models: ids })
|
||||
} 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',
|
||||
})
|
||||
}
|
||||
|
||||
logger.error({ userId, err: error }, '[LLM] User model scan failed')
|
||||
res.status(500).json({ success: false, error: 'Model scan failed' })
|
||||
}
|
||||
}
|
||||
|
||||
async function saveLLMSettings(req, res) {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
const { useOwnLLMSettings, llmApiKey, llmModelName, llmApiUrl } = req.body
|
||||
const { useOwnLLMSettings, llmApiKey, llmModelName, llmApiUrl, llmCompletionModel } = req.body
|
||||
|
||||
logger.debug(
|
||||
{
|
||||
@@ -153,9 +259,9 @@ async function saveLLMSettings(req, res) {
|
||||
|
||||
try {
|
||||
if (useOwnLLMSettings) {
|
||||
const currentUser = await User.findById(userId, 'llmApiKey')
|
||||
const hasExistingApiKey = currentUser && currentUser.llmApiKey
|
||||
|
||||
// overleaf-lab: the API key is optional (a local llama.cpp server has no
|
||||
// auth); only the URL and model name are required. When a key IS provided
|
||||
// it is still encrypted and stored below.
|
||||
if (!llmApiUrl || !llmModelName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
@@ -163,25 +269,19 @@ async function saveLLMSettings(req, res) {
|
||||
'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 || '',
|
||||
// overleaf-lab: per-user inline-completion model; only meaningful with own settings
|
||||
llmCompletionModel: (useOwnLLMSettings && llmCompletionModel) ? llmCompletionModel : '',
|
||||
}
|
||||
|
||||
// Only update API key if a new one is provided
|
||||
if (llmApiKey && llmApiKey.trim() !== '') {
|
||||
updateData.llmApiKey = llmApiKey
|
||||
updateData.llmApiKey = encryptSecret(llmApiKey) // overleaf-lab: encrypt user key at rest
|
||||
}
|
||||
|
||||
await User.updateOne({ _id: userId }, { $set: updateData })
|
||||
@@ -208,5 +308,6 @@ async function saveLLMSettings(req, res) {
|
||||
export default {
|
||||
llmSettingsPage: expressify(llmSettingsPage),
|
||||
checkLLMConnection: expressify(checkLLMConnection),
|
||||
scanUserModels: expressify(scanUserModels),
|
||||
saveLLMSettings: expressify(saveLLMSettings),
|
||||
}
|
||||
|
||||
@@ -11,6 +11,22 @@ block append meta
|
||||
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(','))
|
||||
meta(name='ol-completionModel' data-type='string' content=completionModel)
|
||||
meta(name='ol-llmApiUrlFromEnv' data-type='string' content=llmApiUrlFromEnv ? 'true' : 'false')
|
||||
meta(name='ol-complianceRubrics' data-type='json' content=JSON.stringify(complianceRubrics || []))
|
||||
meta(name='ol-reviewModel' data-type='string' content=reviewModel)
|
||||
meta(name='ol-maxContextTokens' data-type='string' content=String(maxContextTokens))
|
||||
meta(name='ol-reviewMaxTokens' data-type='string' content=String(reviewMaxTokens))
|
||||
//- overleaf-lab: per-feature enable/disable toggles (default true)
|
||||
meta(name='ol-chatEnabled' data-type='json' content=JSON.stringify(chatEnabled))
|
||||
meta(name='ol-completionEnabled' data-type='json' content=JSON.stringify(completionEnabled))
|
||||
meta(name='ol-reviewEnabled' data-type='json' content=JSON.stringify(reviewEnabled))
|
||||
//- overleaf-lab: editable AI prompts and their built-in defaults for reset buttons
|
||||
meta(name='ol-askAiSystemPrompt' data-type='string' content=askAiSystemPrompt)
|
||||
meta(name='ol-errorPrompt' data-type='string' content=errorPrompt)
|
||||
meta(name='ol-reviewSystemPrompt' data-type='string' content=reviewSystemPrompt)
|
||||
meta(name='ol-askAiActionPrompts' data-type='json' content=JSON.stringify(askAiActionPrompts || {}))
|
||||
meta(name='ol-promptDefaults' data-type='json' content=JSON.stringify(promptDefaults || {}))
|
||||
|
||||
block content
|
||||
main#main-content.content.content-alt
|
||||
|
||||
@@ -8,6 +8,8 @@ block vars
|
||||
|
||||
block append meta
|
||||
meta(name='ol-user' data-type='json' content=user)
|
||||
meta(name='ol-featureChatEnabled' data-type='json' content=JSON.stringify(featureFlags.chatEnabled))
|
||||
meta(name='ol-featureCompletionEnabled' data-type='json' content=JSON.stringify(featureFlags.completionEnabled))
|
||||
|
||||
block content
|
||||
main#main-content.content.content-alt
|
||||
|
||||
@@ -107,9 +107,62 @@ const stepNumberStyle: React.CSSProperties = {
|
||||
flexShrink: 0,
|
||||
}
|
||||
|
||||
// overleaf-lab: a small accessible toggle switch (styled from a button) used for
|
||||
// the per-feature enable/disable controls.
|
||||
function ToggleSwitch({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
checked: boolean
|
||||
onChange: (v: boolean) => void
|
||||
label?: string
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
onClick={() => onChange(!checked)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: 42,
|
||||
height: 24,
|
||||
flexShrink: 0,
|
||||
borderRadius: 999,
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
transition: 'background-color 0.15s',
|
||||
backgroundColor: checked
|
||||
? 'var(--bg-accent-01, #0d6efd)'
|
||||
: 'var(--border-color-02, #adb5bd)',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 3,
|
||||
left: checked ? 21 : 3,
|
||||
width: 18,
|
||||
height: 18,
|
||||
borderRadius: '50%',
|
||||
backgroundColor: '#fff',
|
||||
transition: 'left 0.15s',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function LLMAdminSettingsPage() {
|
||||
const { t } = useTranslation()
|
||||
const hasStoredKey = getMeta('ol-hasLlmApiKey') === 'true'
|
||||
// overleaf-lab: true when the shown URL is inherited from the LLM_API_URL env
|
||||
// var rather than saved in the admin settings file.
|
||||
const apiUrlFromEnv = getMeta('ol-llmApiUrlFromEnv') === 'true'
|
||||
|
||||
const [systemPrompt, setSystemPrompt] = useState<string>(
|
||||
(getMeta('ol-systemPrompt') as string) || DEFAULT_SYSTEM_PROMPT
|
||||
@@ -125,6 +178,36 @@ export default function LLMAdminSettingsPage() {
|
||||
.filter(Boolean)
|
||||
)
|
||||
const [availableModels, setAvailableModels] = useState<string[]>([])
|
||||
// overleaf-lab: admin-chosen inline-completion model for the shared backend
|
||||
// ('' = auto, i.e. the first allowed model). Separate from the chat models.
|
||||
const [completionModel, setCompletionModel] = useState<string>(
|
||||
(getMeta('ol-completionModel') as string) || ''
|
||||
)
|
||||
// overleaf-lab: compliance review settings. Rubrics come from a data-type='json'
|
||||
// meta tag, so getMeta returns the parsed value; guard in case it is not an array.
|
||||
const rubricsFromMeta = getMeta('ol-complianceRubrics') as Array<{ id: string; name: string; guidelines: string; scanPatterns?: string }>
|
||||
const initialRubrics = Array.isArray(rubricsFromMeta) ? rubricsFromMeta : []
|
||||
const [complianceRubrics, setComplianceRubrics] = useState<Array<{ id: string; name: string; guidelines: string; scanPatterns?: string }>>(initialRubrics)
|
||||
const [reviewModel, setReviewModel] = useState<string>((getMeta('ol-reviewModel') as string) || '')
|
||||
const [maxContextTokens, setMaxContextTokens] = useState<number>(parseInt((getMeta('ol-maxContextTokens') as string) || '32000', 10) || 32000)
|
||||
// overleaf-lab: budget for the review's JSON answer (the model's max_tokens and
|
||||
// the room reserved for it in the context check).
|
||||
const [reviewMaxTokens, setReviewMaxTokens] = useState<number>(parseInt((getMeta('ol-reviewMaxTokens') as string) || '12000', 10) || 12000)
|
||||
// overleaf-lab: per-feature enable/disable toggles. The metas use data-type='json'
|
||||
// so getMeta returns the parsed boolean; default to true when missing/undefined.
|
||||
const [chatEnabled, setChatEnabled] = useState<boolean>(getMeta('ol-chatEnabled') !== false)
|
||||
const [completionEnabled, setCompletionEnabled] = useState<boolean>(getMeta('ol-completionEnabled') !== false)
|
||||
const [reviewEnabled, setReviewEnabled] = useState<boolean>(getMeta('ol-reviewEnabled') !== false)
|
||||
// overleaf-lab: editable AI prompts. Empty field means the backend uses its
|
||||
// built-in default; promptDefaults feeds the per-field reset buttons.
|
||||
const promptDefaults = (getMeta('ol-promptDefaults') as any) || {}
|
||||
const [askAiSystemPrompt, setAskAiSystemPrompt] = useState<string>((getMeta('ol-askAiSystemPrompt') as string) || '')
|
||||
const [errorPrompt, setErrorPrompt] = useState<string>((getMeta('ol-errorPrompt') as string) || '')
|
||||
const [reviewSystemPrompt, setReviewSystemPrompt] = useState<string>((getMeta('ol-reviewSystemPrompt') as string) || '')
|
||||
const initialActions = (getMeta('ol-askAiActionPrompts') as Record<string, string>) || {}
|
||||
const [askAiActionPrompts, setAskAiActionPrompts] = useState<Record<string, string>>(initialActions && typeof initialActions === 'object' ? initialActions : {})
|
||||
// overleaf-lab: keep the Ask AI action templates block collapsed by default
|
||||
const [showActions, setShowActions] = useState(false)
|
||||
const [scanStatus, setScanStatus] = useState<string | null>(null)
|
||||
const [testStatus, setTestStatus] = useState<string | null>(null)
|
||||
|
||||
@@ -145,7 +228,10 @@ export default function LLMAdminSettingsPage() {
|
||||
}
|
||||
}, [isSuccess])
|
||||
|
||||
const canConnect = !!llmApiUrl && (!!llmApiKey || hasStoredKey)
|
||||
// overleaf-lab: only the URL is required — a local llama.cpp server has no
|
||||
// auth, so scan/test must work with an empty key. The server returns 401 if
|
||||
// it actually needs one.
|
||||
const canConnect = !!llmApiUrl
|
||||
|
||||
const handleSave = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -156,6 +242,18 @@ export default function LLMAdminSettingsPage() {
|
||||
llmApiUrl,
|
||||
llmApiKey,
|
||||
allowedModels,
|
||||
completionModel,
|
||||
complianceRubrics,
|
||||
reviewModel,
|
||||
maxContextTokens,
|
||||
reviewMaxTokens,
|
||||
chatEnabled,
|
||||
completionEnabled,
|
||||
reviewEnabled,
|
||||
askAiSystemPrompt,
|
||||
errorPrompt,
|
||||
reviewSystemPrompt,
|
||||
askAiActionPrompts,
|
||||
},
|
||||
})
|
||||
).catch(() => { })
|
||||
@@ -211,6 +309,23 @@ export default function LLMAdminSettingsPage() {
|
||||
)
|
||||
}
|
||||
|
||||
// overleaf-lab: compliance rubric editing helpers. Each rubric keeps a stable
|
||||
// client-generated id so React keys and immutable updates stay correct.
|
||||
const addRubric = () => {
|
||||
const id = `rubric-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
setComplianceRubrics(prev => [...prev, { id, name: '', guidelines: '', scanPatterns: '' }])
|
||||
}
|
||||
|
||||
const updateRubric = (id: string, field: 'name' | 'guidelines' | 'scanPatterns', value: string) => {
|
||||
setComplianceRubrics(prev =>
|
||||
prev.map(r => (r.id === id ? { ...r, [field]: value } : r))
|
||||
)
|
||||
}
|
||||
|
||||
const removeRubric = (id: string) => {
|
||||
setComplianceRubrics(prev => prev.filter(r => r.id !== id))
|
||||
}
|
||||
|
||||
const allModels = Array.from(new Set([...availableModels, ...allowedModels]))
|
||||
|
||||
return (
|
||||
@@ -233,10 +348,57 @@ export default function LLMAdminSettingsPage() {
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSave}>
|
||||
{/* ── Section 1: API Connection ── */}
|
||||
{/* ── Section 1: Features ── */}
|
||||
{/* overleaf-lab: master on/off switches per AI feature */}
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionHeaderStyle}>
|
||||
<span style={stepNumberStyle}>1</span>
|
||||
<MaterialIcon type="toggle_on" />
|
||||
{t('llm_features', 'Features')}
|
||||
</div>
|
||||
<p style={sectionDescStyle}>
|
||||
{t(
|
||||
'llm_features_desc',
|
||||
'Enable or disable each AI feature for all users. A disabled feature cannot be used by anyone, even with their own API key.'
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div style={{
|
||||
border: '1px solid var(--border-color-01, #dee2e6)',
|
||||
borderRadius: '6px',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{/* overleaf-lab: one toggle switch per feature */}
|
||||
{[
|
||||
{ key: 'chat', on: chatEnabled, set: setChatEnabled, title: t('feature_chat', 'Chat'), help: t('feature_chat_help', 'The AI chat panel and Ask AI on selection.') },
|
||||
{ key: 'completion', on: completionEnabled, set: setCompletionEnabled, title: t('feature_completion', 'Inline completion'), help: t('feature_completion_help', 'Autocomplete suggestions while typing.') },
|
||||
{ key: 'review', on: reviewEnabled, set: setReviewEnabled, title: t('feature_review', 'Compliance review'), help: t('feature_review_help', 'The whole-document review.') },
|
||||
].map((f, i, arr) => (
|
||||
<div
|
||||
key={f.key}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: '1rem',
|
||||
padding: '0.75rem 1rem',
|
||||
borderBottom: i < arr.length - 1 ? '1px solid var(--border-color-01, #dee2e6)' : undefined,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<span style={{ fontWeight: 500 }}>{f.title}</span>
|
||||
<OLFormText style={{ margin: 0 }}>{f.help}</OLFormText>
|
||||
</div>
|
||||
<ToggleSwitch checked={f.on} onChange={f.set} label={f.title} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Section 2: API Connection ── */}
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionHeaderStyle}>
|
||||
<span style={stepNumberStyle}>2</span>
|
||||
<MaterialIcon type="link" />
|
||||
{t('api_connection', 'API Connection')}
|
||||
{testStatus === 'success' && (
|
||||
@@ -264,6 +426,12 @@ export default function LLMAdminSettingsPage() {
|
||||
}
|
||||
placeholder="https://api.example.com/v1"
|
||||
/>
|
||||
{apiUrlFromEnv && (
|
||||
<OLFormText>
|
||||
<MaterialIcon type="info" className="me-1" style={{ fontSize: '0.875rem' }} />
|
||||
{t('llm_admin_from_env', 'Inherited from the LLM_API_URL environment variable. Saving here stores it in the admin settings file.')}
|
||||
</OLFormText>
|
||||
)}
|
||||
</OLFormGroup>
|
||||
|
||||
<OLFormGroup controlId="llm-api-key" style={{ marginBottom: '1rem' }}>
|
||||
@@ -288,6 +456,10 @@ export default function LLMAdminSettingsPage() {
|
||||
{t('llm_api_key_stored', 'An API key is already stored. Leave blank to keep it.')}
|
||||
</OLFormText>
|
||||
)}
|
||||
<OLFormText>
|
||||
<MaterialIcon type="info" className="me-1" style={{ fontSize: '0.875rem' }} />
|
||||
{t('llm_api_key_optional_local', 'Leave blank for a local server with no auth (e.g. a llama.cpp server).')}
|
||||
</OLFormText>
|
||||
</OLFormGroup>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
@@ -317,10 +489,10 @@ export default function LLMAdminSettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Section 2: Model Selection ── */}
|
||||
{/* ── Section 3: Model Selection ── */}
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionHeaderStyle}>
|
||||
<span style={stepNumberStyle}>2</span>
|
||||
<span style={stepNumberStyle}>3</span>
|
||||
<MaterialIcon type="model_training" />
|
||||
{t('model_selection', 'Model Selection')}
|
||||
{allModels.length > 0 && (
|
||||
@@ -427,12 +599,44 @@ export default function LLMAdminSettingsPage() {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* overleaf-lab: admin picks the single shared inline-completion model */}
|
||||
<OLFormGroup controlId="llm-completion-model" style={{ marginTop: allModels.length > 0 ? '1rem' : 0 }}>
|
||||
<OLFormLabel>
|
||||
{t('inline_completion_model', 'Inline completion model')}
|
||||
</OLFormLabel>
|
||||
<select
|
||||
id="llm-completion-model"
|
||||
className="form-select"
|
||||
value={completionModel}
|
||||
onChange={e => setCompletionModel(e.target.value)}
|
||||
>
|
||||
<option value="">
|
||||
{t('auto_first_allowed_model', 'Auto (first allowed model)')}
|
||||
</option>
|
||||
{/* overleaf-lab: turn off shared autocomplete; users can still use their own API key */}
|
||||
<option value="__disabled__">
|
||||
{t('completion_disabled_shared', 'Disabled (only users with their own API key)')}
|
||||
</option>
|
||||
{allModels.map(model => (
|
||||
<option key={model} value={model}>
|
||||
{model}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<OLFormText>
|
||||
{t(
|
||||
'inline_completion_model_admin_help',
|
||||
'Model used for inline autocomplete on the shared backend. Can differ from the chat models. Set to Disabled to turn off shared autocomplete (users with their own API key still get it).'
|
||||
)}
|
||||
</OLFormText>
|
||||
</OLFormGroup>
|
||||
</div>
|
||||
|
||||
{/* ── Section 3: System Prompt ── */}
|
||||
{/* ── Section 4: System Prompt ── */}
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionHeaderStyle}>
|
||||
<span style={stepNumberStyle}>3</span>
|
||||
<span style={stepNumberStyle}>4</span>
|
||||
<MaterialIcon type="description" />
|
||||
{t('system_prompt', 'System Prompt')}
|
||||
</div>
|
||||
@@ -476,6 +680,328 @@ export default function LLMAdminSettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Section 5: Compliance Review ── */}
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionHeaderStyle}>
|
||||
<span style={stepNumberStyle}>5</span>
|
||||
<MaterialIcon type="fact_check" />
|
||||
{t('compliance_review', 'Compliance Review')}
|
||||
</div>
|
||||
<p style={sectionDescStyle}>
|
||||
{t(
|
||||
'compliance_review_desc',
|
||||
'Configure the document compliance review: the guideline rubrics users can check against, the model that runs the review, and the maximum context size.'
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* overleaf-lab: (a) rubrics editor */}
|
||||
{complianceRubrics.length === 0 && (
|
||||
<p style={{ color: 'var(--content-secondary, #6c757d)', fontSize: '0.875rem', marginBottom: '1rem' }}>
|
||||
{t('no_rubrics_yet', 'No rubrics yet. Add one to enable the compliance review for users.')}
|
||||
</p>
|
||||
)}
|
||||
{complianceRubrics.map(rubric => (
|
||||
<div
|
||||
key={rubric.id}
|
||||
style={{
|
||||
border: '1px solid var(--border-color-01, #dee2e6)',
|
||||
borderRadius: '6px',
|
||||
padding: '1rem',
|
||||
marginBottom: '0.75rem',
|
||||
}}
|
||||
>
|
||||
<OLFormGroup controlId={`rubric-name-${rubric.id}`}>
|
||||
<OLFormLabel>
|
||||
{t('rubric_name', 'Rubric name')}
|
||||
</OLFormLabel>
|
||||
<OLFormControl
|
||||
type="text"
|
||||
value={rubric.name}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
updateRubric(rubric.id, 'name', e.target.value)
|
||||
}
|
||||
placeholder={t('rubric_name_placeholder', 'e.g. Thesis writing guidelines')}
|
||||
/>
|
||||
</OLFormGroup>
|
||||
<OLFormGroup controlId={`rubric-guidelines-${rubric.id}`} style={{ marginBottom: '0.5rem' }}>
|
||||
<OLFormLabel>
|
||||
{t('rubric_guidelines', 'Guidelines')}
|
||||
</OLFormLabel>
|
||||
<OLFormControl
|
||||
as="textarea"
|
||||
rows={6}
|
||||
value={rubric.guidelines}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||
updateRubric(rubric.id, 'guidelines', e.target.value)
|
||||
}
|
||||
style={{ fontFamily: 'monospace', fontSize: '0.8125rem' }}
|
||||
/>
|
||||
</OLFormGroup>
|
||||
{/* overleaf-lab: per-rubric mechanical scans; policy
|
||||
patterns live next to the guidelines they verify */}
|
||||
<OLFormGroup controlId={`rubric-scans-${rubric.id}`} style={{ marginBottom: '0.5rem' }}>
|
||||
<OLFormLabel>
|
||||
{t('rubric_scan_patterns', 'Scan patterns (optional, one per line)')}
|
||||
</OLFormLabel>
|
||||
<OLFormControl
|
||||
as="textarea"
|
||||
rows={3}
|
||||
value={rubric.scanPatterns || ''}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||
updateRubric(rubric.id, 'scanPatterns', e.target.value)
|
||||
}
|
||||
placeholder={'First person :: (?<![\\w.@/])(io|noi|ho)\\b\nWikipedia :: wikipedia'}
|
||||
style={{ fontFamily: 'monospace', fontSize: '0.8125rem' }}
|
||||
/>
|
||||
<OLFormText>
|
||||
{t(
|
||||
'rubric_scan_patterns_help',
|
||||
'"Label :: regex" (case-insensitive; a plain word works too). The whole source is scanned in code, exhaustively, and the matches are handed to the model as candidates to judge in context. Use it for the pattern-like requirements of THIS rubric (words to avoid, forbidden constructs), in the language of your guidelines.'
|
||||
)}
|
||||
</OLFormText>
|
||||
</OLFormGroup>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<OLButton
|
||||
variant="danger"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => removeRubric(rubric.id)}
|
||||
>
|
||||
<MaterialIcon type="delete" className="me-1" style={{ fontSize: '1rem' }} />
|
||||
{t('remove', 'Remove')}
|
||||
</OLButton>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<div style={{ marginBottom: '0.5rem' }}>
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={addRubric}
|
||||
>
|
||||
<MaterialIcon type="add" className="me-1" style={{ fontSize: '1rem' }} />
|
||||
{t('add_rubric', 'Add rubric')}
|
||||
</OLButton>
|
||||
</div>
|
||||
<OLFormText>
|
||||
{t(
|
||||
'compliance_rubrics_help',
|
||||
'Paste your thesis or internship writing guidelines. The AI checks the whole document against each rubric and returns a report.'
|
||||
)}
|
||||
</OLFormText>
|
||||
|
||||
{/* overleaf-lab: (b) review model selector */}
|
||||
<OLFormGroup controlId="llm-review-model" style={{ marginTop: '1.25rem' }}>
|
||||
<OLFormLabel>
|
||||
{t('review_model', 'Review model')}
|
||||
</OLFormLabel>
|
||||
<select
|
||||
id="llm-review-model"
|
||||
className="form-select"
|
||||
value={reviewModel}
|
||||
onChange={e => setReviewModel(e.target.value)}
|
||||
>
|
||||
<option value="">
|
||||
{t('review_model_shared_default', 'Shared chat model (default)')}
|
||||
</option>
|
||||
{allModels.map(model => (
|
||||
<option key={model} value={model}>
|
||||
{model}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<OLFormText>
|
||||
{t(
|
||||
'review_model_help',
|
||||
'Model used to run the compliance review. Pick a large-context model. Defaults to the shared chat model.'
|
||||
)}
|
||||
</OLFormText>
|
||||
</OLFormGroup>
|
||||
|
||||
{/* overleaf-lab: (c) max context tokens */}
|
||||
<OLFormGroup controlId="llm-max-context-tokens" style={{ marginTop: '1rem', marginBottom: 0 }}>
|
||||
<OLFormLabel>
|
||||
{t('max_context_tokens', 'Max context tokens')}
|
||||
</OLFormLabel>
|
||||
<OLFormControl
|
||||
type="number"
|
||||
value={maxContextTokens}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const parsed = parseInt(e.target.value, 10)
|
||||
if (!isNaN(parsed)) {
|
||||
setMaxContextTokens(parsed)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<OLFormText>
|
||||
{t(
|
||||
'max_context_tokens_help',
|
||||
'The context window (in tokens) of the review model, as configured on your llama.cpp server (the -c value, divided by --parallel). The review refuses documents that would not fit. No auto-detection.'
|
||||
)}
|
||||
</OLFormText>
|
||||
</OLFormGroup>
|
||||
|
||||
{/* overleaf-lab: budget for the review answer itself */}
|
||||
<OLFormGroup controlId="llm-review-max-tokens" style={{ marginTop: '1rem', marginBottom: 0 }}>
|
||||
<OLFormLabel>
|
||||
{t('review_max_tokens', 'Review answer budget (tokens)')}
|
||||
</OLFormLabel>
|
||||
<OLFormControl
|
||||
type="number"
|
||||
value={reviewMaxTokens}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const parsed = parseInt(e.target.value, 10)
|
||||
if (!isNaN(parsed)) {
|
||||
setReviewMaxTokens(parsed)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<OLFormText>
|
||||
{t(
|
||||
'review_max_tokens_help',
|
||||
'Upper limit for the answer of each review pass. The actual room adapts to what the document leaves free inside Max context tokens, so a large value here never blocks a long document; it only allows more thorough per-requirement analyses when there is room.'
|
||||
)}
|
||||
</OLFormText>
|
||||
</OLFormGroup>
|
||||
|
||||
</div>
|
||||
|
||||
{/* ── Section 6: AI Prompts ── */}
|
||||
{/* overleaf-lab: editable prompts behind each AI feature; empty means built-in default */}
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionHeaderStyle}>
|
||||
<span style={stepNumberStyle}>6</span>
|
||||
<MaterialIcon type="edit_note" />
|
||||
{t('ai_prompts', 'AI Prompts')}
|
||||
</div>
|
||||
<p style={sectionDescStyle}>
|
||||
{t(
|
||||
'ai_prompts_desc',
|
||||
'Customize the prompts behind each AI feature. Leave a field empty to use the built-in default.'
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* overleaf-lab: (a) the three standalone prompts, each with a reset link */}
|
||||
{[
|
||||
{
|
||||
key: 'askAiSystemPrompt',
|
||||
value: askAiSystemPrompt,
|
||||
set: setAskAiSystemPrompt,
|
||||
def: promptDefaults.askAiSystemPrompt,
|
||||
label: t('ask_ai_behavior_prompt', 'Ask AI behavior prompt'),
|
||||
help: t('ask_ai_behavior_prompt_help', 'System prompt for the selection toolbar (Ask AI / paraphrase / rewrite).'),
|
||||
},
|
||||
{
|
||||
key: 'errorPrompt',
|
||||
value: errorPrompt,
|
||||
set: setErrorPrompt,
|
||||
def: promptDefaults.errorPrompt,
|
||||
label: t('error_help_prompt', 'Error help prompt'),
|
||||
help: t('error_help_prompt_help', 'Instructions appended when a user clicks Ask AI about a compile error.'),
|
||||
},
|
||||
{
|
||||
key: 'reviewSystemPrompt',
|
||||
value: reviewSystemPrompt,
|
||||
set: setReviewSystemPrompt,
|
||||
def: promptDefaults.reviewSystemPrompt,
|
||||
label: t('review_system_prompt', 'Review system prompt'),
|
||||
help: t('review_system_prompt_help', 'System prompt for the whole-document compliance review.'),
|
||||
},
|
||||
].map(field => (
|
||||
<div key={field.key} style={{ marginBottom: '1.25rem' }}>
|
||||
<OLFormGroup controlId={`llm-${field.key}`} style={{ marginBottom: '0.25rem' }}>
|
||||
<OLFormLabel>
|
||||
{field.label}
|
||||
</OLFormLabel>
|
||||
<OLFormControl
|
||||
as="textarea"
|
||||
rows={6}
|
||||
value={field.value}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||
field.set(e.target.value)
|
||||
}
|
||||
style={{ fontFamily: 'monospace', fontSize: '0.8125rem' }}
|
||||
/>
|
||||
</OLFormGroup>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<OLFormText style={{ margin: 0 }}>
|
||||
{field.help}
|
||||
</OLFormText>
|
||||
<OLButton
|
||||
variant="link"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => field.set(field.def || '')}
|
||||
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>
|
||||
))}
|
||||
|
||||
{/* overleaf-lab: (b) collapsible Ask AI action templates, one textarea per action */}
|
||||
<div style={{ marginTop: '0.5rem' }}>
|
||||
<OLButton
|
||||
variant="link"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => setShowActions(v => !v)}
|
||||
style={{ padding: 0, fontSize: '0.875rem' }}
|
||||
>
|
||||
<MaterialIcon type={showActions ? 'expand_less' : 'expand_more'} className="me-1" style={{ fontSize: '1.125rem' }} />
|
||||
{t('ask_ai_action_templates', 'Ask AI action templates')}
|
||||
</OLButton>
|
||||
|
||||
{showActions && (
|
||||
<div style={{ marginTop: '0.75rem' }}>
|
||||
<OLFormText style={{ marginTop: 0, marginBottom: '0.75rem' }}>
|
||||
{t(
|
||||
'ask_ai_action_help',
|
||||
'Each template runs on the selected text. Use {{selection}} where the selected text should be inserted; if omitted, it is appended.'
|
||||
)}
|
||||
</OLFormText>
|
||||
{['paraphrase', 'academic', 'concise', 'punchy', 'split', 'join', 'summarize', 'explain', 'title', 'abstract'].map(key => (
|
||||
<div key={key} style={{ marginBottom: '1rem' }}>
|
||||
<OLFormGroup controlId={`llm-action-${key}`} style={{ marginBottom: '0.25rem' }}>
|
||||
<OLFormLabel>
|
||||
{t(`ask_ai_action_${key}`, key.charAt(0).toUpperCase() + key.slice(1))}
|
||||
</OLFormLabel>
|
||||
<OLFormControl
|
||||
as="textarea"
|
||||
rows={4}
|
||||
value={askAiActionPrompts[key] || ''}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value
|
||||
setAskAiActionPrompts(prev => ({ ...prev, [key]: value }))
|
||||
}}
|
||||
style={{ fontFamily: 'monospace', fontSize: '0.8125rem' }}
|
||||
/>
|
||||
</OLFormGroup>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<OLButton
|
||||
variant="link"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const def = promptDefaults.askAiActionPrompts?.[key] || ''
|
||||
setAskAiActionPrompts(prev => ({ ...prev, [key]: def }))
|
||||
}}
|
||||
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>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Notifications ── */}
|
||||
{showSuccess && (
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
|
||||
@@ -0,0 +1,519 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import { useLLMCompliance } from '../hooks/use-llm-compliance'
|
||||
import type {
|
||||
ComplianceItem,
|
||||
ComplianceStatus,
|
||||
} from '../hooks/use-llm-compliance'
|
||||
|
||||
// overleaf-lab: visual mapping for each requirement status. Colours reuse the
|
||||
// app's design tokens with hard-coded fallbacks so the pane still reads well if
|
||||
// a token is missing.
|
||||
const STATUS_STYLE: Record<
|
||||
ComplianceStatus,
|
||||
{ icon: string; color: string }
|
||||
> = {
|
||||
ok: { icon: 'check_circle', color: 'var(--green-60, #198754)' },
|
||||
partial: { icon: 'warning', color: 'var(--yellow-60, #f59e0b)' },
|
||||
missing: { icon: 'cancel', color: 'var(--red-60, #dc3545)' },
|
||||
na: { icon: 'remove', color: 'var(--content-secondary, #6c757d)' },
|
||||
}
|
||||
|
||||
// overleaf-lab: a muted-but-readable text color that adapts to the theme. It is a
|
||||
// slightly faded version of the ADAPTIVE primary token (--content-primary-themed,
|
||||
// the one that actually flips on dark), so grey text stays legible on the dark
|
||||
// theme. If color-mix is unsupported the value is ignored and the text falls back
|
||||
// to the inherited (readable) color.
|
||||
const MUTED =
|
||||
'color-mix(in srgb, var(--content-primary-themed) 72%, transparent)'
|
||||
|
||||
// overleaf-lab: mm:ss for the progress readout.
|
||||
function formatDuration(ms: number): string {
|
||||
const totalSeconds = Math.max(0, Math.round(ms / 1000))
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
return `${minutes}:${String(seconds).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function ComplianceReportItem({ item }: { item: ComplianceItem }) {
|
||||
const { t } = useTranslation()
|
||||
const statusStyle = STATUS_STYLE[item.status] || STATUS_STYLE.na
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
padding: '8px 0',
|
||||
borderTop: '1px solid var(--border-divider, rgba(125,125,125,0.2))',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<MaterialIcon
|
||||
type={statusStyle.icon}
|
||||
style={{ color: statusStyle.color, flexShrink: 0, marginTop: 2 }}
|
||||
/>
|
||||
<div style={{ minWidth: 0, flex: 1, overflowWrap: 'anywhere' }}>
|
||||
<div style={{ fontWeight: 'bold' }}>{item.requirement}</div>
|
||||
{item.evidence && (
|
||||
<div style={{ color: MUTED, fontSize: '0.85em', marginTop: 2 }}>
|
||||
{t('evidence', 'Evidence')}: {item.evidence}
|
||||
</div>
|
||||
)}
|
||||
{item.suggestion && (
|
||||
<div style={{ fontStyle: 'italic', fontSize: '0.9em', marginTop: 2 }}>
|
||||
{t('suggestion', 'Suggestion')}: {item.suggestion}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LLMCompliancePane() {
|
||||
const { t } = useTranslation()
|
||||
const {
|
||||
rubrics,
|
||||
rubricsLoaded,
|
||||
selectedRubricId,
|
||||
setSelectedRubricId,
|
||||
phase,
|
||||
position,
|
||||
progress,
|
||||
result,
|
||||
errorInfo,
|
||||
runReview,
|
||||
cancelReview,
|
||||
downloadReport,
|
||||
hasRubrics,
|
||||
} = useLLMCompliance()
|
||||
|
||||
if (!rubricsLoaded) {
|
||||
return (
|
||||
<div style={{ padding: 12, color: MUTED }}>
|
||||
{t('loading', 'Loading')}...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!hasRubrics) {
|
||||
return (
|
||||
<div style={{ padding: 12, color: MUTED }}>
|
||||
{t(
|
||||
'compliance_no_rubrics',
|
||||
'No review rubrics have been configured. Ask your administrator to add one in the LLM settings.'
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// overleaf-lab: a job is active (in queue or running) while we poll it.
|
||||
const isActive = phase === 'queued' || phase === 'running'
|
||||
// overleaf-lab: the run button is only shown/enabled in the resting phases.
|
||||
const showRunButton = phase === 'idle' || phase === 'done' || phase === 'error'
|
||||
|
||||
// overleaf-lab: map the fixed backend error codes to friendly copy. The code
|
||||
// now lives in errorInfo.errorCode.
|
||||
const renderError = (): React.ReactNode => {
|
||||
if (!errorInfo) return null
|
||||
|
||||
let message: string
|
||||
switch (errorInfo.errorCode) {
|
||||
case 'too_long':
|
||||
message = t(
|
||||
'review_too_long',
|
||||
'The document plus the room reserved for the answer does not fit the review model context window. Shorten the document, or ask your administrator to lower the review answer budget or raise the context window.'
|
||||
)
|
||||
break
|
||||
case 'busy':
|
||||
message = t(
|
||||
'review_busy',
|
||||
'A review is already running. Please try again in a moment.'
|
||||
)
|
||||
break
|
||||
case 'model_unavailable':
|
||||
message = t(
|
||||
'review_model_unavailable',
|
||||
'The review model is not available on the backend right now.'
|
||||
)
|
||||
break
|
||||
case 'not_configured':
|
||||
message = t(
|
||||
'review_not_configured',
|
||||
'The LLM backend is not configured. Contact your administrator.'
|
||||
)
|
||||
break
|
||||
case 'empty_document':
|
||||
message = t('review_empty', 'This project has no text to review.')
|
||||
break
|
||||
case 'disabled':
|
||||
message = t('review_disabled', 'The AI service is disabled.')
|
||||
break
|
||||
case 'not_found':
|
||||
message = t(
|
||||
'review_not_found',
|
||||
'The review was not found or has expired.'
|
||||
)
|
||||
break
|
||||
default:
|
||||
message =
|
||||
errorInfo.message ||
|
||||
t('review_failed', 'The review failed. Please try again.')
|
||||
}
|
||||
|
||||
const showTokens =
|
||||
errorInfo.errorCode === 'too_long' &&
|
||||
errorInfo.documentTokensEstimate != null &&
|
||||
errorInfo.maxContextTokens != null
|
||||
|
||||
// overleaf-lab: show the WHOLE equation. The refusal is caused by
|
||||
// prompt + reserved answer room exceeding the limit, so printing only
|
||||
// "prompt / limit" showed numbers that looked like they fitted.
|
||||
const promptTokens = errorInfo.documentTokensEstimate || 0
|
||||
const answerTokens = errorInfo.reviewMaxTokens || 0
|
||||
const totalTokens = promptTokens + answerTokens
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: 10,
|
||||
borderRadius: 6,
|
||||
color: 'var(--red-60, #dc3545)',
|
||||
border: '1px solid var(--red-60, #dc3545)',
|
||||
background: 'rgba(220,53,69,0.08)',
|
||||
overflowWrap: 'anywhere',
|
||||
}}
|
||||
>
|
||||
<div>{message}</div>
|
||||
{showTokens && (
|
||||
<div style={{ color: MUTED, fontSize: '0.85em', marginTop: 4 }}>
|
||||
{answerTokens > 0 ? (
|
||||
<>
|
||||
{t('review_tokens_document', 'Document')}:{' '}
|
||||
{promptTokens.toLocaleString()} +{' '}
|
||||
{t('review_tokens_answer', 'reserved for the answer')}:{' '}
|
||||
{answerTokens.toLocaleString()} ={' '}
|
||||
{totalTokens.toLocaleString()}, {t('review_tokens_limit', 'limit')}:{' '}
|
||||
{(errorInfo.maxContextTokens || 0).toLocaleString()}{' '}
|
||||
{t('tokens', 'tokens')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{promptTokens.toLocaleString()} /{' '}
|
||||
{(errorInfo.maxContextTokens || 0).toLocaleString()}{' '}
|
||||
{t('tokens', 'tokens')}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderResult = (): React.ReactNode => {
|
||||
if (!result) return null
|
||||
|
||||
const counts = result.items.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.status] = (acc[item.status] || 0) + 1
|
||||
return acc
|
||||
},
|
||||
{} as Record<ComplianceStatus, number>
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{/* overleaf-lab: download the report as Markdown */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={downloadReport}
|
||||
>
|
||||
<MaterialIcon type="download" />{' '}
|
||||
{t('download_report', 'Download report')}
|
||||
</OLButton>
|
||||
</div>
|
||||
|
||||
{/* overleaf-lab: nudge users to keep the report; a new review is a
|
||||
heavy, minutes-long operation for the server. */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '0.8em',
|
||||
color: MUTED,
|
||||
marginBottom: 8,
|
||||
overflowWrap: 'anywhere',
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
'review_download_hint',
|
||||
'Tip: download this report to keep it. Running a new review is a heavy operation for the server, so avoid repeating it unnecessarily.'
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* overleaf-lab: compact counts summary */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 12,
|
||||
flexWrap: 'wrap',
|
||||
fontSize: '0.85em',
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: STATUS_STYLE.ok.color }}>
|
||||
{t('status_ok', 'OK')}: {counts.ok || 0}
|
||||
</span>
|
||||
<span style={{ color: STATUS_STYLE.partial.color }}>
|
||||
{t('status_partial', 'Partial')}: {counts.partial || 0}
|
||||
</span>
|
||||
<span style={{ color: STATUS_STYLE.missing.color }}>
|
||||
{t('status_missing', 'Missing')}: {counts.missing || 0}
|
||||
</span>
|
||||
<span style={{ color: MUTED }}>
|
||||
{t('status_na', 'N/A')}: {counts.na || 0}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Summary block (synthesized in a final pass; may be empty if that
|
||||
best-effort call failed, in which case show nothing) */}
|
||||
{result.summary && (
|
||||
<div
|
||||
style={{
|
||||
padding: 10,
|
||||
borderRadius: 6,
|
||||
// overleaf-lab: translucent grey (works on light and dark)
|
||||
// instead of the fixed light --bg-light-secondary, which was
|
||||
// a white box on the dark theme, and an adaptive text color.
|
||||
background: 'rgba(125,125,125,0.14)',
|
||||
color: 'var(--content-primary-themed)',
|
||||
overflowWrap: 'anywhere',
|
||||
}}
|
||||
>
|
||||
{result.summary}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ color: MUTED, fontSize: '0.85em', marginTop: 6 }}>
|
||||
{t('model_label', 'Model')}: {result.model} - ~
|
||||
{result.documentTokensEstimate} {t('tokens', 'tokens')}
|
||||
</div>
|
||||
|
||||
{/* Scrollable requirements list */}
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
minHeight: 0,
|
||||
flex: 1,
|
||||
}}
|
||||
>
|
||||
{result.items.map((item, idx) => (
|
||||
<ComplianceReportItem key={idx} item={item} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
minHeight: 0,
|
||||
flex: 1,
|
||||
padding: 12,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* Header row: rubric selector + run button */}
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<select
|
||||
className="form-select"
|
||||
value={selectedRubricId}
|
||||
onChange={e => setSelectedRubricId(e.target.value)}
|
||||
disabled={isActive}
|
||||
aria-label={t('review_rubric', 'Review rubric')}
|
||||
style={{ flex: 1, minWidth: 0 }}
|
||||
>
|
||||
{rubrics.map(rubric => (
|
||||
<option key={rubric.id} value={rubric.id}>
|
||||
{rubric.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{showRunButton && (
|
||||
<OLButton
|
||||
variant="primary"
|
||||
type="button"
|
||||
onClick={runReview}
|
||||
disabled={!selectedRubricId}
|
||||
>
|
||||
<MaterialIcon type="fact_check" />{' '}
|
||||
{t('run_review', 'Run review')}
|
||||
</OLButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* overleaf-lab: queued state - position note + cancel */}
|
||||
{phase === 'queued' && (
|
||||
<div style={{ marginTop: 12, color: MUTED }}>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
|
||||
<MaterialIcon type="schedule" />
|
||||
<span>{t('review_queued', 'In queue')}</span>
|
||||
</div>
|
||||
{position > 0 && (
|
||||
<div style={{ marginTop: 4, fontSize: '0.85em' }}>
|
||||
{t('review_queue_position', 'Requests ahead of you:')}{' '}
|
||||
{position}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={cancelReview}
|
||||
>
|
||||
<MaterialIcon type="close" /> {t('cancel', 'Cancel')}
|
||||
</OLButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* overleaf-lab: running state - real pass-based progress + cancel.
|
||||
The review runs one model call per rubric requirement, so the bar
|
||||
moves on actual completions, not on a time estimate. */}
|
||||
{phase === 'running' && (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
color: MUTED,
|
||||
}}
|
||||
>
|
||||
<MaterialIcon
|
||||
type={
|
||||
progress?.phase === 'summarizing'
|
||||
? 'edit_note'
|
||||
: progress?.phase === 'checking'
|
||||
? 'rule'
|
||||
: 'hourglass_empty'
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
{progress?.phase === 'summarizing'
|
||||
? t('review_summarizing', 'Writing the summary...')
|
||||
: progress?.phase === 'checking'
|
||||
? t('review_checking', 'Checking requirement') +
|
||||
` ${Math.min(progress.passesDone + 1, progress.passesTotal)}/${progress.passesTotal}`
|
||||
: t(
|
||||
'review_preparing',
|
||||
'Preparing the document...'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* overleaf-lab: the requirement being checked right now */}
|
||||
{progress?.phase === 'checking' && progress.currentRequirement && (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 4,
|
||||
fontSize: '0.8em',
|
||||
color: MUTED,
|
||||
overflowWrap: 'anywhere',
|
||||
}}
|
||||
>
|
||||
{progress.currentRequirement}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{progress && progress.passesTotal > 0 && (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
background: 'rgba(125,125,125,0.2)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: `${Math.round(
|
||||
(progress.phase === 'summarizing'
|
||||
? 0.97
|
||||
: progress.passesDone /
|
||||
progress.passesTotal) * 100
|
||||
)}%`,
|
||||
background: 'var(--green-60, #198754)',
|
||||
transition: 'width 0.5s ease',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
marginTop: 4,
|
||||
fontSize: '0.8em',
|
||||
color: MUTED,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<span>{formatDuration(progress.elapsedMs)}</span>
|
||||
{/* Same index as the "Checking requirement" label above:
|
||||
the pass being worked on, not the completed count,
|
||||
so the two numbers never disagree on screen. */}
|
||||
<span>
|
||||
{Math.min(
|
||||
progress.passesDone + 1,
|
||||
progress.passesTotal
|
||||
)}
|
||||
/{progress.passesTotal}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={cancelReview}
|
||||
>
|
||||
<MaterialIcon type="close" /> {t('cancel', 'Cancel')}
|
||||
</OLButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phase === 'error' && renderError()}
|
||||
{phase === 'done' && renderResult()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LLMCompliancePane
|
||||
@@ -1,17 +1,149 @@
|
||||
import React from 'react'
|
||||
import React, { useEffect, useState } 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 LLMCompliancePane from './llm-compliance-pane'
|
||||
import { RailElement } from '@/features/ide-react/util/rail-types'
|
||||
import getMeta from '@/utils/meta'
|
||||
import { useLLMFeatures } from '../hooks/use-llm-features'
|
||||
|
||||
function LLMRailPane() {
|
||||
const { t } = useTranslation()
|
||||
// overleaf-lab: switch between the chat assistant and the whole-document
|
||||
// compliance review inside the same rail panel.
|
||||
const [tab, setTab] = useState<'chat' | 'review'>('chat')
|
||||
|
||||
// overleaf-lab: super-admin feature flags decide which tabs exist. Chat maps
|
||||
// to the chat pane, review to the compliance pane. Until the flags load both
|
||||
// default to visible (fail open) so nothing flickers off then on.
|
||||
const features = useLLMFeatures()
|
||||
const chatVisible = features.chatEnabled
|
||||
const reviewVisible = features.reviewEnabled
|
||||
|
||||
// overleaf-lab: never leave the active tab pointing at a hidden pane. Once the
|
||||
// flags are loaded, fall back to whichever tab is still visible.
|
||||
useEffect(() => {
|
||||
if (!features.loaded) return
|
||||
if (tab === 'chat' && !chatVisible && reviewVisible) {
|
||||
setTab('review')
|
||||
} else if (tab === 'review' && !reviewVisible && chatVisible) {
|
||||
setTab('chat')
|
||||
}
|
||||
}, [features.loaded, chatVisible, reviewVisible, tab])
|
||||
|
||||
const tabButtonStyle = (active: boolean): React.CSSProperties => ({
|
||||
appearance: 'none',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: '6px 12px',
|
||||
cursor: 'pointer',
|
||||
// overleaf-lab: inherit the panel's themed text color for BOTH tabs (same
|
||||
// color as the "AI Assistant" header, so legible on light and dark), and
|
||||
// distinguish the active one with weight + an accent underline, the inactive
|
||||
// one with reduced opacity. Using inherit avoids depending on a token that
|
||||
// did not flip on dark (--content-primary read dark-on-dark before).
|
||||
color: 'inherit',
|
||||
opacity: active ? 1 : 0.6,
|
||||
fontWeight: active ? 600 : 400,
|
||||
borderBottom: active
|
||||
? '2px solid var(--bg-accent-01, #3265b5)'
|
||||
: '2px solid transparent',
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="llm-rail-panel">
|
||||
<RailPanelHeader title={t('ai_assistant', 'AI Assistant')} />
|
||||
<LLMChatPane />
|
||||
|
||||
{features.loaded && !chatVisible && !reviewVisible ? (
|
||||
// overleaf-lab: the admin disabled every AI feature for this
|
||||
// project, so there is nothing to show.
|
||||
<div
|
||||
style={{
|
||||
padding: '12px',
|
||||
color: 'var(--content-secondary, inherit)',
|
||||
opacity: 0.7,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{t(
|
||||
'llm_all_features_disabled',
|
||||
'AI features are currently disabled by the administrator.'
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* overleaf-lab: only show the two-tab bar when BOTH panes are
|
||||
available; with a single feature the tab bar is redundant. */}
|
||||
{chatVisible && reviewVisible && (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
borderBottom: '1px solid var(--border-divider, rgba(125,125,125,0.2))',
|
||||
}}
|
||||
role="tablist"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === 'chat'}
|
||||
style={tabButtonStyle(tab === 'chat')}
|
||||
onClick={() => setTab('chat')}
|
||||
>
|
||||
{t('chat', 'Chat')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === 'review'}
|
||||
style={tabButtonStyle(tab === 'review')}
|
||||
onClick={() => setTab('review')}
|
||||
>
|
||||
{t('review', 'Review')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* overleaf-lab: when both panes are enabled keep BOTH mounted
|
||||
and toggle visibility, so a running review (and its
|
||||
polling/queue state) survives a tab switch. With a single
|
||||
feature only that pane is mounted, always shown. */}
|
||||
{chatVisible && (
|
||||
<div
|
||||
style={{
|
||||
display: reviewVisible
|
||||
? tab === 'chat'
|
||||
? 'flex'
|
||||
: 'none'
|
||||
: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<LLMChatPane />
|
||||
</div>
|
||||
)}
|
||||
{reviewVisible && (
|
||||
<div
|
||||
style={{
|
||||
display: chatVisible
|
||||
? tab === 'review'
|
||||
? 'flex'
|
||||
: 'none'
|
||||
: 'flex',
|
||||
flexDirection: 'column',
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<LLMCompliancePane />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ 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'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
type Props = {
|
||||
initialSettings?: {
|
||||
@@ -15,15 +16,36 @@ type Props = {
|
||||
modelName?: string
|
||||
apiUrl?: string
|
||||
hasApiKey?: boolean
|
||||
completionModel?: string
|
||||
}
|
||||
}
|
||||
|
||||
// overleaf-lab: provider presets. Selecting a known provider fills in its
|
||||
// OpenAI-compatible base URL; "Custom" reveals the free-text URL field.
|
||||
const OPENAI_URL = 'https://api.openai.com/v1'
|
||||
const ANTHROPIC_URL = 'https://api.anthropic.com/v1'
|
||||
|
||||
function providerFromUrl(url: string): 'openai' | 'anthropic' | 'custom' {
|
||||
if (url.includes('openai.com')) return 'openai'
|
||||
if (url.includes('anthropic.com')) return 'anthropic'
|
||||
return 'custom'
|
||||
}
|
||||
|
||||
export default function LLMSettingsSection({ initialSettings }: Props) {
|
||||
const { t } = useTranslation()
|
||||
// overleaf-lab: super-admin feature flags for this user settings page. There
|
||||
// is no project id here, so the flags come from server-rendered meta (both
|
||||
// default to true when absent). Hide the personal chat-model UI when chat is
|
||||
// off and the inline-completion-model UI when completion is off. The backend
|
||||
// only renders this page when at least one is enabled.
|
||||
const chatEnabled = getMeta('ol-featureChatEnabled') !== false
|
||||
const completionEnabled = getMeta('ol-featureCompletionEnabled') !== false
|
||||
const [useOwnLLMSettings, setUseOwnLLMSettings] = useState(
|
||||
initialSettings?.useOwnSettings || false
|
||||
)
|
||||
const [llmApiKey, setLlmApiKey] = useState('')
|
||||
// overleaf-lab: llmModelName is a comma-separated list of personal chat
|
||||
// models; the first id is the default in the editor's chat model picker.
|
||||
const [llmModelName, setLlmModelName] = useState(
|
||||
initialSettings?.modelName || ''
|
||||
)
|
||||
@@ -31,6 +53,19 @@ export default function LLMSettingsSection({ initialSettings }: Props) {
|
||||
const [llmHasApiKey, setLlmHasApiKey] = useState(
|
||||
initialSettings?.hasApiKey || false
|
||||
)
|
||||
// overleaf-lab: per-user inline-completion model ('' = local/shared model)
|
||||
const [llmCompletionModel, setLlmCompletionModel] = useState(
|
||||
initialSettings?.completionModel || ''
|
||||
)
|
||||
const [customCompletionSelected, setCustomCompletionSelected] = useState(false)
|
||||
// overleaf-lab: provider selector, derived initially from the stored URL
|
||||
const [provider, setProvider] = useState<'openai' | 'anthropic' | 'custom'>(
|
||||
providerFromUrl(initialSettings?.apiUrl || '')
|
||||
)
|
||||
// overleaf-lab: model ids discovered via "Scan for models"
|
||||
const [scannedModels, setScannedModels] = useState<string[]>([])
|
||||
const [isScanning, setIsScanning] = useState(false)
|
||||
const [scanError, setScanError] = useState<string | null>(null)
|
||||
const [isCheckingConnection, setIsCheckingConnection] = useState(false)
|
||||
const [connectionCheckResult, setConnectionCheckResult] = useState<{
|
||||
success: boolean
|
||||
@@ -53,6 +88,41 @@ export default function LLMSettingsSection({ initialSettings }: Props) {
|
||||
}
|
||||
}, [isLlmSuccess])
|
||||
|
||||
const handleProviderChange = (value: string) => {
|
||||
const next = value as 'openai' | 'anthropic' | 'custom'
|
||||
setProvider(next)
|
||||
// Switching provider invalidates any previously scanned model list.
|
||||
setScannedModels([])
|
||||
setScanError(null)
|
||||
if (next === 'openai') {
|
||||
setLlmApiUrl(OPENAI_URL)
|
||||
} else if (next === 'anthropic') {
|
||||
setLlmApiUrl(ANTHROPIC_URL)
|
||||
}
|
||||
// 'custom' keeps whatever URL is currently entered and reveals the field.
|
||||
}
|
||||
|
||||
const handleScanModels = async () => {
|
||||
setIsScanning(true)
|
||||
setScanError(null)
|
||||
try {
|
||||
const response = await postJSON('/user/llm-settings/models', {
|
||||
body: {
|
||||
apiUrl: llmApiUrl,
|
||||
apiKey: llmApiKey || undefined,
|
||||
},
|
||||
})
|
||||
setScannedModels(
|
||||
Array.isArray(response.models) ? response.models : []
|
||||
)
|
||||
} catch (err: any) {
|
||||
setScanError(err.message || 'Failed to scan for models')
|
||||
setScannedModels([])
|
||||
} finally {
|
||||
setIsScanning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCheckLLMConnection = async () => {
|
||||
setIsCheckingConnection(true)
|
||||
setConnectionCheckResult(null)
|
||||
@@ -61,7 +131,8 @@ export default function LLMSettingsSection({ initialSettings }: Props) {
|
||||
body: {
|
||||
apiUrl: llmApiUrl,
|
||||
apiKey: llmApiKey || undefined,
|
||||
modelName: llmModelName,
|
||||
// Check uses the default (first) chat model id.
|
||||
modelName: llmModelName.split(',')[0].trim(),
|
||||
},
|
||||
})
|
||||
setConnectionCheckResult({
|
||||
@@ -84,8 +155,9 @@ export default function LLMSettingsSection({ initialSettings }: Props) {
|
||||
body: {
|
||||
useOwnLLMSettings,
|
||||
llmApiKey: llmApiKey || undefined,
|
||||
llmModelName,
|
||||
llmModelName, // overleaf-lab: comma-separated chat model ids
|
||||
llmApiUrl,
|
||||
llmCompletionModel, // overleaf-lab: resolved completion model id ('' = local)
|
||||
},
|
||||
})
|
||||
)
|
||||
@@ -105,6 +177,11 @@ export default function LLMSettingsSection({ initialSettings }: Props) {
|
||||
setLlmApiKey('')
|
||||
setLlmModelName('')
|
||||
setLlmApiUrl('')
|
||||
setLlmCompletionModel('')
|
||||
setCustomCompletionSelected(false)
|
||||
setProvider('custom')
|
||||
setScannedModels([])
|
||||
setScanError(null)
|
||||
setConnectionCheckResult(null)
|
||||
|
||||
runLlmAsync(
|
||||
@@ -114,12 +191,76 @@ export default function LLMSettingsSection({ initialSettings }: Props) {
|
||||
llmApiKey: undefined,
|
||||
llmModelName: '',
|
||||
llmApiUrl: '',
|
||||
llmCompletionModel: '',
|
||||
},
|
||||
})
|
||||
).catch(() => { })
|
||||
}
|
||||
}
|
||||
|
||||
// overleaf-lab: currently selected chat model ids (comma-separated -> array).
|
||||
const selectedChatModels = llmModelName
|
||||
.split(',')
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0)
|
||||
|
||||
// Options for the multi-select: scanned ids plus any already-selected ids
|
||||
// that aren't in the scan (so an existing selection is always visible even
|
||||
// before scanning). Deduplicated, scanned ids first.
|
||||
const chatModelOptions = Array.from(
|
||||
new Set([...scannedModels, ...selectedChatModels])
|
||||
)
|
||||
|
||||
const handleChatModelsSelect = (
|
||||
e: React.ChangeEvent<HTMLSelectElement>
|
||||
) => {
|
||||
const selected = Array.from(e.target.selectedOptions).map(o => o.value)
|
||||
setLlmModelName(selected.join(','))
|
||||
}
|
||||
|
||||
// overleaf-lab: the completion control is a binary choice: the shared model
|
||||
// chosen by the admin (value '') OR the user's OWN personal model. We never
|
||||
// present a list of shared/scanned models to pick from; the personal option
|
||||
// is a provider preset (a cheap model on the user's own endpoint) or a
|
||||
// custom id they type in below.
|
||||
const completionOptions: { value: string; label: string }[] = [
|
||||
{ value: '', label: t('shared_completion_model_admin', 'Shared model (chosen by admin)') },
|
||||
]
|
||||
if (llmApiUrl.includes('openai.com')) {
|
||||
completionOptions.push({ value: 'gpt-4.1-nano', label: 'gpt-4.1-nano' })
|
||||
completionOptions.push({ value: 'gpt-4o-mini', label: 'gpt-4o-mini' })
|
||||
}
|
||||
if (llmApiUrl.includes('anthropic.com')) {
|
||||
completionOptions.push({ value: 'claude-haiku-4-5', label: 'claude-haiku-4-5' })
|
||||
}
|
||||
completionOptions.push({
|
||||
value: '__custom__',
|
||||
label: t('other_enter_completion_model_id', 'Other (enter model id)…'),
|
||||
})
|
||||
|
||||
// Real model ids offered as presets (includes '' for local; excludes the
|
||||
// synthetic '__custom__' sentinel). A stored value outside this set is custom.
|
||||
const completionPresetValues = completionOptions
|
||||
.map(o => o.value)
|
||||
.filter(v => v !== '__custom__')
|
||||
const showCustomCompletion =
|
||||
customCompletionSelected ||
|
||||
(llmCompletionModel !== '' && !completionPresetValues.includes(llmCompletionModel))
|
||||
const completionSelectValue = showCustomCompletion ? '__custom__' : llmCompletionModel
|
||||
|
||||
const handleCompletionSelect = (value: string) => {
|
||||
if (value === '__custom__') {
|
||||
setCustomCompletionSelected(true)
|
||||
} else {
|
||||
setCustomCompletionSelected(false)
|
||||
setLlmCompletionModel(value)
|
||||
}
|
||||
}
|
||||
|
||||
// overleaf-lab: only the URL is required — a local server may have no auth,
|
||||
// so an empty key is valid. A stored key (llmHasApiKey) still works too.
|
||||
const scanDisabled = isScanning || !llmApiUrl
|
||||
|
||||
return (
|
||||
<>
|
||||
<OLFormGroup>
|
||||
@@ -144,6 +285,43 @@ export default function LLMSettingsSection({ initialSettings }: Props) {
|
||||
handleSaveLLMSettings()
|
||||
}}
|
||||
>
|
||||
<OLFormGroup controlId="llm-provider-input">
|
||||
<OLFormLabel>
|
||||
{t('llm_provider', 'Provider')}
|
||||
</OLFormLabel>
|
||||
<select
|
||||
id="llm-provider-input"
|
||||
className="form-select"
|
||||
value={provider}
|
||||
onChange={e => handleProviderChange(e.target.value)}
|
||||
>
|
||||
<option value="openai">OpenAI</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="custom">
|
||||
{t('custom', 'Custom')}
|
||||
</option>
|
||||
</select>
|
||||
</OLFormGroup>
|
||||
|
||||
<OLFormGroup controlId="llm-api-url-input">
|
||||
<OLFormLabel>API URL</OLFormLabel>
|
||||
{provider === 'custom' ? (
|
||||
<OLFormControl
|
||||
type="text"
|
||||
value={llmApiUrl}
|
||||
onChange={e => setLlmApiUrl(e.target.value)}
|
||||
placeholder="e.g., https://api.openai.com/v1"
|
||||
/>
|
||||
) : (
|
||||
<OLFormControl
|
||||
type="text"
|
||||
value={llmApiUrl}
|
||||
readOnly
|
||||
plaintext
|
||||
/>
|
||||
)}
|
||||
</OLFormGroup>
|
||||
|
||||
<OLFormGroup controlId="llm-api-key-input">
|
||||
<OLFormLabel>API Key</OLFormLabel>
|
||||
<OLFormControl
|
||||
@@ -160,24 +338,119 @@ export default function LLMSettingsSection({ initialSettings }: Props) {
|
||||
)}
|
||||
</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>
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={handleScanModels}
|
||||
disabled={scanDisabled}
|
||||
isLoading={isScanning}
|
||||
loadingLabel={t('scanning', 'Scanning…')}
|
||||
>
|
||||
{t('scan_for_models', 'Scan for models')}
|
||||
</OLButton>
|
||||
{scannedModels.length > 0 && (
|
||||
<OLFormText>
|
||||
{t(
|
||||
'scan_found_n_models',
|
||||
'Found {{count}} models.'
|
||||
).replace('{{count}}', String(scannedModels.length))}
|
||||
</OLFormText>
|
||||
)}
|
||||
</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"
|
||||
/>
|
||||
{scanError && (
|
||||
<OLFormGroup>
|
||||
<OLNotification type="error" content={scanError} />
|
||||
</OLFormGroup>
|
||||
)}
|
||||
|
||||
{/* overleaf-lab: personal CHAT model UI, hidden when the admin
|
||||
disabled the chat feature for this user. */}
|
||||
{chatEnabled && (
|
||||
<OLFormGroup controlId="llm-chat-models-input">
|
||||
<OLFormLabel>
|
||||
{t('chat_models', 'Chat models')}
|
||||
</OLFormLabel>
|
||||
{chatModelOptions.length > 0 && (
|
||||
<select
|
||||
id="llm-chat-models-input"
|
||||
className="form-select"
|
||||
multiple
|
||||
size={Math.min(
|
||||
8,
|
||||
Math.max(6, chatModelOptions.length)
|
||||
)}
|
||||
value={selectedChatModels}
|
||||
onChange={handleChatModelsSelect}
|
||||
>
|
||||
{chatModelOptions.map(m => (
|
||||
<option key={m} value={m}>
|
||||
{m}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<OLFormControl
|
||||
type="text"
|
||||
value={llmModelName}
|
||||
onChange={e => setLlmModelName(e.target.value)}
|
||||
placeholder="e.g., gpt-4o, gpt-4.1"
|
||||
style={{ marginTop: '0.5rem' }}
|
||||
/>
|
||||
<OLFormText>
|
||||
{t(
|
||||
'chat_models_help',
|
||||
'Pick one or more chat models (or type comma-separated ids). The first one is the default in the editor.'
|
||||
)}
|
||||
</OLFormText>
|
||||
</OLFormGroup>
|
||||
)}
|
||||
|
||||
{/* overleaf-lab: personal inline-COMPLETION model UI, hidden when
|
||||
the admin disabled the completion feature for this user. */}
|
||||
{completionEnabled && (
|
||||
<OLFormGroup controlId="llm-completion-model-input">
|
||||
<OLFormLabel>
|
||||
{t('inline_completion_model', 'Inline completion model')}
|
||||
</OLFormLabel>
|
||||
<select
|
||||
id="llm-completion-model-input"
|
||||
className="form-select"
|
||||
value={completionSelectValue}
|
||||
onChange={e => handleCompletionSelect(e.target.value)}
|
||||
>
|
||||
{completionOptions.map(opt => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{showCustomCompletion && (
|
||||
<OLFormControl
|
||||
type="text"
|
||||
value={llmCompletionModel}
|
||||
onChange={e => setLlmCompletionModel(e.target.value)}
|
||||
placeholder="e.g., gpt-4o-mini"
|
||||
style={{ marginTop: '0.5rem' }}
|
||||
/>
|
||||
)}
|
||||
<OLFormText>
|
||||
{t(
|
||||
'inline_completion_model_help',
|
||||
'Local / shared model is free and low-latency. gpt-4.1-nano / gpt-4o-mini cost roughly a few cents per month; claude-haiku costs more (completion runs at high frequency).'
|
||||
)}
|
||||
</OLFormText>
|
||||
</OLFormGroup>
|
||||
)}
|
||||
|
||||
<OLFormGroup>
|
||||
<OLFormText>
|
||||
{t(
|
||||
'llm_cheap_models_hint',
|
||||
'💡 Cheap models — completion (high-frequency, keep it cheap): OpenAI gpt-4.1-nano / gpt-4o-mini · Anthropic claude-haiku-4-5. Chat (quality): OpenAI gpt-4o / gpt-4.1 · Anthropic claude-sonnet-4-6 / claude-sonnet-5. You can pick several chat models and switch between them in the editor.'
|
||||
)}
|
||||
</OLFormText>
|
||||
</OLFormGroup>
|
||||
|
||||
{connectionCheckResult && (
|
||||
@@ -197,7 +470,6 @@ export default function LLMSettingsSection({ initialSettings }: Props) {
|
||||
disabled={
|
||||
isCheckingConnection ||
|
||||
!llmApiUrl ||
|
||||
(!llmApiKey && !llmHasApiKey) ||
|
||||
!llmModelName
|
||||
}
|
||||
isLoading={isCheckingConnection}
|
||||
|
||||
@@ -4,12 +4,23 @@ 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'
|
||||
import { useLLMFeatures } from '../hooks/use-llm-features'
|
||||
|
||||
function LLMSourceEditorComponent() {
|
||||
const view = useCodeMirrorViewContext()
|
||||
const toolbarRef = useRef<LLMToolbarHandle>(null)
|
||||
const [extensionInstalled, setExtensionInstalled] = useState(false)
|
||||
|
||||
// overleaf-lab: publish the chat feature flag on window so the review-tooltip
|
||||
// core patch can hide its "Ask AI" entry when chat is disabled. That entry lives
|
||||
// in a core component that only knows the module flag (llmEnabled), not the
|
||||
// runtime chat toggle; this bridges the runtime flag to it. Defaults to enabled
|
||||
// until the flags load, matching the pre-flag behavior.
|
||||
const features = useLLMFeatures()
|
||||
useEffect(() => {
|
||||
;(window as any).__llmChatEnabled = features.chatEnabled
|
||||
}, [features.chatEnabled])
|
||||
|
||||
// Install inline completion extension
|
||||
useEffect(() => {
|
||||
if (!view || extensionInstalled) return
|
||||
@@ -32,13 +43,15 @@ function LLMSourceEditorComponent() {
|
||||
const handleAskAI = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail
|
||||
if (!detail?.text || !view || !toolbarRef.current) return
|
||||
// overleaf-lab: chat disabled by admin -> do not open the toolbar.
|
||||
if (features.chatEnabled === false) 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])
|
||||
}, [view, features.chatEnabled])
|
||||
|
||||
return <LLMToolbar ref={toolbarRef} />
|
||||
}
|
||||
|
||||
@@ -13,6 +13,9 @@ import React, {
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { marked } from 'marked'
|
||||
import getMeta from '@/utils/meta'
|
||||
import { readSelectedModel } from '../utils/llm-selected-model'
|
||||
import { useLLMFeatures } from '../hooks/use-llm-features'
|
||||
import { useLLMPrompts } from '../hooks/use-llm-prompts'
|
||||
|
||||
export type LLMToolbarHandle = {
|
||||
show: (view: EditorView) => void
|
||||
@@ -86,6 +89,15 @@ const Spinner = () => (
|
||||
)
|
||||
|
||||
const LLMToolbar = forwardRef<LLMToolbarHandle, {}>((_, ref) => {
|
||||
// overleaf-lab: the floating "Ask AI" selection toolbar is part of the chat
|
||||
// feature, so it obeys the super-admin chat flag. Called unconditionally to
|
||||
// respect the rules of hooks; the returned JSX is gated further down.
|
||||
const features = useLLMFeatures()
|
||||
// overleaf-lab: admin-editable prompts. Called unconditionally at the top so
|
||||
// the rules of hooks hold. `prompts` is read directly inside postToAPI (a
|
||||
// closure recreated each render), falling back to the hardcoded strings when
|
||||
// a field is missing or the fetch has not resolved.
|
||||
const { prompts } = useLLMPrompts()
|
||||
const [anchorShown, setAnchorShown] = useState(false)
|
||||
const [panelRect, setPanelRect] = useState({ top: 0, left: 0, width: 520 })
|
||||
const [anchorPos, setAnchorPos] = useState({ top: 0, left: 0 })
|
||||
@@ -119,30 +131,85 @@ const LLMToolbar = forwardRef<LLMToolbarHandle, {}>((_, ref) => {
|
||||
// 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}`,
|
||||
1: `Paraphrase the following LaTeX text. Keep every LaTeX command, math, and citation key intact. Output only the paraphrased text, with no preamble, no explanation, and no code fences.\n\n${selectionText}`,
|
||||
2: `Rewrite the following LaTeX text in fluent, formal academic English. Preserve every LaTeX command, math, and citation key. Output only the rewritten text, with no preamble and no code fences.\n\n${selectionText}`,
|
||||
3: `Rewrite the following LaTeX text more concisely, preserving its meaning and every LaTeX command, math, and citation. Output only the rewritten text, nothing else.\n\n${selectionText}`,
|
||||
4: `Rewrite the following LaTeX text in a punchier, more engaging style while keeping it accurate. Preserve every LaTeX command, math, and citation. Output only the rewritten text, nothing else.\n\n${selectionText}`,
|
||||
5: `Split the following LaTeX paragraph into several shorter, well-structured paragraphs. Keep the wording and all LaTeX; only add paragraph breaks. Output only the resulting LaTeX, nothing else.\n\n${selectionText}`,
|
||||
6: `Join the following LaTeX paragraphs into a single cohesive paragraph, preserving every LaTeX command, math, and citation. Output only the resulting paragraph, nothing else.\n\n${selectionText}`,
|
||||
7: `Summarize the following LaTeX text concisely. Output only the summary as plain LaTeX, with no preamble and no code fences.\n\n${selectionText}`,
|
||||
8: `Explain the following LaTeX text clearly and concisely for the author:\n\n${selectionText}`,
|
||||
9: `Propose one concise, specific academic title for the following content. Output only the title text: no quotes, no label, no trailing period.\n\n${selectionText}`,
|
||||
10: `Write a single self-contained academic abstract (about 150 to 250 words) for the following content. Output only the abstract text: no heading, no label, and no code fences.\n\n${selectionText}`,
|
||||
}
|
||||
|
||||
// overleaf-lab: numeric transform modes map to admin action keys. Mode 0
|
||||
// is free-form chat and has no template.
|
||||
const modeActionKey: Record<number, string> = {
|
||||
1: 'paraphrase',
|
||||
2: 'academic',
|
||||
3: 'concise',
|
||||
4: 'punchy',
|
||||
5: 'split',
|
||||
6: 'join',
|
||||
7: 'summarize',
|
||||
8: 'explain',
|
||||
9: 'title',
|
||||
10: 'abstract',
|
||||
}
|
||||
|
||||
// overleaf-lab: hardcoded system prompt kept verbatim as the fallback
|
||||
// used whenever the admin prompt is absent or the fetch has not resolved.
|
||||
const fallbackSystemPrompt =
|
||||
'You are a LaTeX writing assistant embedded in an editor. Preserve existing LaTeX commands, math, and citation keys exactly, and reply in the same language as the input. When asked to rewrite or transform text, return only the resulting text, with no preamble and no Markdown code fences.'
|
||||
|
||||
// overleaf-lab: prefer the admin system prompt when it is a non-empty
|
||||
// string, otherwise keep the hardcoded one.
|
||||
const systemContent =
|
||||
typeof prompts?.askAiSystemPrompt === 'string' &&
|
||||
prompts.askAiSystemPrompt.trim() !== ''
|
||||
? prompts.askAiSystemPrompt
|
||||
: fallbackSystemPrompt
|
||||
|
||||
// overleaf-lab: build the user content. Mode 0 stays free-form chat
|
||||
// (uses the user's `ask`). Transform modes prefer the admin template,
|
||||
// substituting the literal {{selection}} with the selected text (or
|
||||
// appending it when the template omits the placeholder), and fall back
|
||||
// to the hardcoded modePrompts entry when no admin template exists.
|
||||
let userContent: string
|
||||
if (mode >= 1) {
|
||||
const actionKey = modeActionKey[mode]
|
||||
const template = actionKey
|
||||
? prompts?.askAiActionPrompts?.[actionKey]
|
||||
: undefined
|
||||
if (typeof template === 'string' && template.trim() !== '') {
|
||||
userContent = template.includes('{{selection}}')
|
||||
? template.split('{{selection}}').join(selectionText)
|
||||
: `${template}\n\n${selectionText}`
|
||||
} else {
|
||||
userContent = modePrompts[mode] || ask
|
||||
}
|
||||
} else {
|
||||
userContent = modePrompts[mode] || ask
|
||||
}
|
||||
|
||||
const messages = [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'You are a helpful LaTeX writing assistant. Respond with clean LaTeX code when appropriate.',
|
||||
content: systemContent,
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: modePrompts[mode] || ask,
|
||||
content: userContent,
|
||||
},
|
||||
]
|
||||
|
||||
// overleaf-lab: reuse the model currently selected in the chat panel so
|
||||
// "Ask AI" follows the same model. When nothing is stored (chat never
|
||||
// opened / storage disabled) we omit `model`, keeping the previous
|
||||
// behavior: the backend uses the user's personal model or the shared one.
|
||||
const selectedModelId = readSelectedModel()
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/project/${projectId}/llm/chat`, {
|
||||
method: 'POST',
|
||||
@@ -151,7 +218,9 @@ const LLMToolbar = forwardRef<LLMToolbarHandle, {}>((_, ref) => {
|
||||
'X-CSRF-Token': csrfToken,
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ messages }),
|
||||
body: JSON.stringify(
|
||||
selectedModelId ? { messages, model: selectedModelId } : { messages }
|
||||
),
|
||||
})
|
||||
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
@@ -423,6 +492,23 @@ const LLMToolbar = forwardRef<LLMToolbarHandle, {}>((_, ref) => {
|
||||
Math.round(window.innerHeight * 0.5)
|
||||
)
|
||||
|
||||
// overleaf-lab: once the flags load, if chat is disabled for this project the
|
||||
// toolbar must never surface its anchor/menu/panels. Render the same inert
|
||||
// fixed container it shows when there is no selection, so nothing appears.
|
||||
if (features.loaded && features.chatEnabled === false) {
|
||||
return (
|
||||
<div
|
||||
ref={wrapRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 9999,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapRef}
|
||||
@@ -439,8 +525,10 @@ const LLMToolbar = forwardRef<LLMToolbarHandle, {}>((_, ref) => {
|
||||
.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-send{position:absolute;right:44px;top:50%;transform:translateY(-50%);width:34px;height:34px;border-radius:9px;background:rgba(16,185,129,0.18);border:1px solid rgba(16,185,129,0.4);color:#eafff5;font-size:18px;line-height:1;display:flex;align-items:center;justify-content:center;cursor:pointer}
|
||||
.llm-send:hover{background:rgba(16,185,129,0.3)}
|
||||
.llm-close{position:absolute;right:8px;top:50%;transform:translateY(-50%);width:34px;height:34px;border-radius:9px;background:rgba(255,255,255,0.08);border:1px solid rgba(255,255,255,0.16);color:#e6eef8;font-size:20px;line-height:1;display:flex;align-items:center;justify-content:center;cursor:pointer}
|
||||
.llm-close:hover{background:rgba(255,255,255,0.16)}
|
||||
.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)}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { memo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import getMeta from '@/utils/meta'
|
||||
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
import OLIconButton from '@/shared/components/ol/ol-icon-button'
|
||||
import { useLLMChat } from '../hooks/use-llm-chat'
|
||||
import { useLLMPrompts } from '../hooks/use-llm-prompts'
|
||||
|
||||
interface LogEntry {
|
||||
key?: string
|
||||
@@ -22,11 +24,43 @@ interface AskAIButtonProps {
|
||||
function PdfLogEntryAskAIButton({ logEntry }: AskAIButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const { modelsLoaded, hasModels } = useLLMChat()
|
||||
// overleaf-lab: admin-editable prompts. Called unconditionally at the top so
|
||||
// the rules of hooks hold. Only the trailing instruction block is overridable;
|
||||
// when absent the hardcoded block is used as the fallback.
|
||||
const { prompts } = useLLMPrompts()
|
||||
|
||||
const handleAskAI = useCallback(async () => {
|
||||
if (!logEntry) return
|
||||
try {
|
||||
const errorMessage = formatErrorForLLM(logEntry)
|
||||
// overleaf-lab: fetch a few source lines around the error line so the
|
||||
// model sees the actual code, not just the log. Best-effort: on any
|
||||
// failure we send the error without the snippet.
|
||||
let sourceSnippet = ''
|
||||
if (logEntry.file && logEntry.line) {
|
||||
try {
|
||||
const projectId = getMeta('ol-project_id')
|
||||
const params = new URLSearchParams({
|
||||
file: String(logEntry.file),
|
||||
line: String(logEntry.line),
|
||||
})
|
||||
const resp = await fetch(
|
||||
`/project/${projectId}/llm/source-context?${params.toString()}`,
|
||||
{ credentials: 'same-origin' }
|
||||
)
|
||||
const json = await resp.json()
|
||||
if (json?.ok && json.snippet) {
|
||||
sourceSnippet = json.snippet
|
||||
}
|
||||
} catch {
|
||||
// ignore, proceed without source context
|
||||
}
|
||||
}
|
||||
|
||||
const errorMessage = formatErrorForLLM(
|
||||
logEntry,
|
||||
sourceSnippet,
|
||||
prompts?.errorPrompt
|
||||
)
|
||||
|
||||
// Open the LLM chat rail tab first
|
||||
window.dispatchEvent(
|
||||
@@ -46,7 +80,7 @@ function PdfLogEntryAskAIButton({ logEntry }: AskAIButtonProps) {
|
||||
} catch (err) {
|
||||
console.error('[LLM] Failed to send error to AI:', err)
|
||||
}
|
||||
}, [logEntry])
|
||||
}, [logEntry, prompts?.errorPrompt])
|
||||
|
||||
// logEntry may be undefined in certain contexts; only show for errors
|
||||
if (!logEntry || logEntry.level !== 'error') {
|
||||
@@ -76,7 +110,11 @@ function PdfLogEntryAskAIButton({ logEntry }: AskAIButtonProps) {
|
||||
)
|
||||
}
|
||||
|
||||
function formatErrorForLLM(logEntry: LogEntry): string {
|
||||
function formatErrorForLLM(
|
||||
logEntry: LogEntry,
|
||||
sourceSnippet?: string,
|
||||
errorPrompt?: string
|
||||
): string {
|
||||
const parts = [
|
||||
'🔴 **LaTeX Compilation Error**',
|
||||
'',
|
||||
@@ -93,17 +131,37 @@ function formatErrorForLLM(logEntry: LogEntry): string {
|
||||
parts.push(`**Line:** ${logEntry.line}`)
|
||||
}
|
||||
|
||||
// overleaf-lab: include the source lines around the error (the > line is the
|
||||
// one the compiler flagged) so the model can point to and fix the actual code.
|
||||
if (sourceSnippet) {
|
||||
parts.push(
|
||||
'',
|
||||
'**Source around the error (the line marked with > is where the compiler reported it):**',
|
||||
'```latex',
|
||||
sourceSnippet,
|
||||
'```',
|
||||
''
|
||||
)
|
||||
}
|
||||
|
||||
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'
|
||||
)
|
||||
// overleaf-lab: the trailing instruction block is admin-overridable. Use the
|
||||
// admin errorPrompt when it is a non-empty string, else the hardcoded block
|
||||
// (kept verbatim) as the fallback.
|
||||
if (typeof errorPrompt === 'string' && errorPrompt.trim() !== '') {
|
||||
parts.push(errorPrompt)
|
||||
} else {
|
||||
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')
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import getMeta from '@/utils/meta'
|
||||
import {
|
||||
readSelectedModel,
|
||||
writeSelectedModel,
|
||||
} from '../utils/llm-selected-model'
|
||||
|
||||
interface Message {
|
||||
role: 'system' | 'user' | 'assistant'
|
||||
@@ -119,7 +123,15 @@ export const useLLMChat = () => {
|
||||
const defaultModel =
|
||||
modelsFromBackend.find((m: LLMModel) => m.isDefault) ||
|
||||
modelsFromBackend[0]
|
||||
setSelectedModel(defaultModel?.id || '')
|
||||
// overleaf-lab: restore the last selected model if it is still
|
||||
// available (remembers the choice and self-heals a stale/removed
|
||||
// id by falling back to the default). See utils/llm-selected-model.
|
||||
const stored = readSelectedModel()
|
||||
const restored =
|
||||
stored && modelsFromBackend.some((m: LLMModel) => m.id === stored)
|
||||
? stored
|
||||
: defaultModel?.id || ''
|
||||
setSelectedModel(restored)
|
||||
setModelsLoaded(true)
|
||||
} catch (err) {
|
||||
console.error('[LLMChat] Failed to fetch models:', err)
|
||||
@@ -133,6 +145,12 @@ export const useLLMChat = () => {
|
||||
fetchModels()
|
||||
}, [projectId])
|
||||
|
||||
// overleaf-lab: persist the selected model so the selection toolbar ("Ask AI")
|
||||
// can reuse it. Runs for the initial default and every user change.
|
||||
useEffect(() => {
|
||||
writeSelectedModel(selectedModel)
|
||||
}, [selectedModel])
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (userMessage: string) => {
|
||||
const newMessages: Message[] = [
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
// overleaf-lab: shapes for the document compliance review feature. The backend is
|
||||
// now a job queue: start enqueues a review and returns a jobId, the client polls
|
||||
// a status endpoint, and can cancel. Every endpoint always returns HTTP 200 and
|
||||
// the body carries either a success payload (ok:true) or a logical error.
|
||||
|
||||
export interface ComplianceRubric {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export type ComplianceStatus = 'ok' | 'partial' | 'missing' | 'na'
|
||||
|
||||
export interface ComplianceItem {
|
||||
requirement: string
|
||||
status: ComplianceStatus
|
||||
evidence: string
|
||||
suggestion: string
|
||||
}
|
||||
|
||||
export interface ComplianceResult {
|
||||
ok: true
|
||||
rubric: ComplianceRubric
|
||||
model: string
|
||||
documentTokensEstimate: number
|
||||
maxContextTokens: number
|
||||
summary: string
|
||||
items: ComplianceItem[]
|
||||
}
|
||||
|
||||
export type ComplianceErrorCode =
|
||||
| 'disabled'
|
||||
| 'busy'
|
||||
| 'no_rubric'
|
||||
| 'not_configured'
|
||||
| 'empty_document'
|
||||
| 'too_long'
|
||||
| 'model_unavailable'
|
||||
| 'not_found'
|
||||
| 'backend_error'
|
||||
| 'failed'
|
||||
|
||||
// overleaf-lab: kept for callers that still reference the raw error shape.
|
||||
export interface ComplianceError {
|
||||
ok: false
|
||||
error: ComplianceErrorCode
|
||||
message: string
|
||||
documentTokensEstimate?: number
|
||||
maxContextTokens?: number
|
||||
}
|
||||
|
||||
// overleaf-lab: the phase drives the whole pane UI.
|
||||
export type CompliancePhase = 'idle' | 'queued' | 'running' | 'done' | 'error'
|
||||
|
||||
// overleaf-lab: live progress of a running review. The review is multi-pass (one
|
||||
// model call per rubric requirement), so progress is REAL: passes completed over
|
||||
// total, plus the requirement currently being checked. 'preparing' is the document
|
||||
// assembly before the first pass, 'summarizing' the final small synthesis call.
|
||||
export interface ReviewProgress {
|
||||
phase: 'preparing' | 'checking' | 'summarizing'
|
||||
passesDone: number
|
||||
passesTotal: number
|
||||
currentRequirement: string
|
||||
elapsedMs: number
|
||||
}
|
||||
|
||||
// overleaf-lab: normalized error info the pane renders. The code lives in
|
||||
// `errorCode` (from either the start body's `error` or the status body's
|
||||
// `errorCode`), so the pane reads a single field.
|
||||
export interface ComplianceErrorInfo {
|
||||
errorCode: ComplianceErrorCode | string
|
||||
message?: string
|
||||
documentTokensEstimate?: number
|
||||
maxContextTokens?: number
|
||||
// overleaf-lab: the room reserved for the answer. It is part of the sum that
|
||||
// caused a too_long refusal, so the UI must show it or the numbers look wrong.
|
||||
reviewMaxTokens?: number
|
||||
}
|
||||
|
||||
interface RubricsResponse {
|
||||
rubrics?: ComplianceRubric[]
|
||||
}
|
||||
|
||||
// overleaf-lab: escape user/model text before embedding it in the HTML report.
|
||||
function escapeHtml(s: string): string {
|
||||
return String(s || '').replace(
|
||||
/[&<>"']/g,
|
||||
c =>
|
||||
(({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}) as Record<string, string>)[c] || c
|
||||
)
|
||||
}
|
||||
|
||||
// overleaf-lab: build a self-contained, styled HTML report from a finished result.
|
||||
// It opens in any browser and can be saved as PDF via the browser's Print dialog,
|
||||
// which avoids bundling a PDF library. Colors are fixed (a printable light document),
|
||||
// independent of the app theme.
|
||||
function buildReportHtml(result: ComplianceResult): string {
|
||||
const counts: Record<ComplianceStatus, number> = {
|
||||
ok: 0,
|
||||
partial: 0,
|
||||
missing: 0,
|
||||
na: 0,
|
||||
}
|
||||
for (const item of result.items) {
|
||||
counts[item.status] = (counts[item.status] || 0) + 1
|
||||
}
|
||||
|
||||
const statusLabel: Record<ComplianceStatus, string> = {
|
||||
ok: 'OK',
|
||||
partial: 'Partial',
|
||||
missing: 'Missing',
|
||||
na: 'N/A',
|
||||
}
|
||||
const statusColor: Record<ComplianceStatus, string> = {
|
||||
ok: '#198754',
|
||||
partial: '#f59e0b',
|
||||
missing: '#dc3545',
|
||||
na: '#6c757d',
|
||||
}
|
||||
|
||||
const itemsHtml = result.items
|
||||
.map(item => {
|
||||
const color = statusColor[item.status] || '#6c757d'
|
||||
const label = statusLabel[item.status] || 'N/A'
|
||||
const evidence = item.evidence
|
||||
? `<div class="ev"><strong>Evidence:</strong> ${escapeHtml(item.evidence)}</div>`
|
||||
: ''
|
||||
const suggestion = item.suggestion
|
||||
? `<div class="sg"><strong>Suggestion:</strong> ${escapeHtml(item.suggestion)}</div>`
|
||||
: ''
|
||||
return `<div class="item"><div class="req"><span class="badge" style="background:${color}">${label}</span> ${escapeHtml(
|
||||
item.requirement
|
||||
)}</div>${evidence}${suggestion}</div>`
|
||||
})
|
||||
.join('\n')
|
||||
|
||||
const title = `Compliance review - ${escapeHtml(result.rubric.name)}`
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title>
|
||||
<style>
|
||||
body{font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;max-width:820px;margin:2rem auto;padding:0 1rem;color:#1a1a1a;line-height:1.5}
|
||||
h1{font-size:1.5rem;margin:0 0 .25rem}
|
||||
h2{font-size:1.15rem;margin:1.5rem 0 .5rem}
|
||||
.meta{color:#6c757d;font-size:.9rem;margin-bottom:1rem}
|
||||
.summary{background:#f3f4f6;border-radius:8px;padding:1rem;margin:1rem 0;white-space:pre-wrap}
|
||||
.counts span{display:inline-block;margin-right:1.25rem;font-weight:600}
|
||||
.item{border-top:1px solid #e5e7eb;padding:.75rem 0}
|
||||
.req{font-weight:600}
|
||||
.badge{color:#fff;border-radius:4px;padding:1px 7px;font-size:.72rem;margin-right:6px;vertical-align:middle}
|
||||
.ev,.sg{font-size:.9rem;color:#374151;margin-top:.3rem}
|
||||
@media print{body{margin:0;max-width:none}}
|
||||
</style></head>
|
||||
<body>
|
||||
<h1>${title}</h1>
|
||||
<div class="meta">Model: ${escapeHtml(result.model)} - about ${result.documentTokensEstimate} tokens</div>
|
||||
<div class="counts">
|
||||
<span style="color:#198754">OK ${counts.ok || 0}</span>
|
||||
<span style="color:#f59e0b">Partial ${counts.partial || 0}</span>
|
||||
<span style="color:#dc3545">Missing ${counts.missing || 0}</span>
|
||||
<span style="color:#6c757d">N/A ${counts.na || 0}</span>
|
||||
</div>
|
||||
<div class="summary">${escapeHtml(result.summary)}</div>
|
||||
<h2>Requirements</h2>
|
||||
${itemsHtml}
|
||||
<p class="meta">Tip: use your browser's Print dialog to save this report as PDF.</p>
|
||||
</body></html>`
|
||||
}
|
||||
|
||||
export const useLLMCompliance = () => {
|
||||
const projectId = getMeta('ol-project_id')
|
||||
|
||||
const [rubrics, setRubrics] = useState<ComplianceRubric[]>([])
|
||||
const [rubricsLoaded, setRubricsLoaded] = useState(false)
|
||||
const [selectedRubricId, setSelectedRubricId] = useState('')
|
||||
const [phase, setPhase] = useState<CompliancePhase>('idle')
|
||||
const [position, setPosition] = useState(0)
|
||||
const [result, setResult] = useState<ComplianceResult | null>(null)
|
||||
const [errorInfo, setErrorInfo] = useState<ComplianceErrorInfo | null>(null)
|
||||
const [progress, setProgress] = useState<ReviewProgress | null>(null)
|
||||
|
||||
// overleaf-lab: refs so async callbacks and the unload handler always see the
|
||||
// current values without re-subscribing.
|
||||
const jobIdRef = useRef<string | null>(null)
|
||||
const phaseRef = useRef<CompliancePhase>('idle')
|
||||
const pollRef = useRef<number | null>(null)
|
||||
const mountedRef = useRef(true)
|
||||
|
||||
// overleaf-lab: keep the phase ref in sync for the beforeunload handler.
|
||||
useEffect(() => {
|
||||
phaseRef.current = phase
|
||||
}, [phase])
|
||||
|
||||
// overleaf-lab: tick the elapsed clock locally between the 2s status polls so it
|
||||
// moves smoothly; each poll re-syncs it from the server (authoritative). The bar
|
||||
// itself only moves on real pass completions reported by the server.
|
||||
useEffect(() => {
|
||||
if (phase !== 'running') return undefined
|
||||
const id = window.setInterval(() => {
|
||||
setProgress(prev =>
|
||||
prev ? { ...prev, elapsedMs: prev.elapsedMs + 1000 } : prev
|
||||
)
|
||||
}, 1000)
|
||||
return () => window.clearInterval(id)
|
||||
}, [phase])
|
||||
|
||||
const stopPolling = useCallback(() => {
|
||||
if (pollRef.current != null) {
|
||||
clearInterval(pollRef.current)
|
||||
pollRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// overleaf-lab: mount/unmount bookkeeping. On unmount we only stop polling; we
|
||||
// do NOT cancel the job, since the pane may just be hidden by a tab switch.
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
stopPolling()
|
||||
}
|
||||
}, [stopPolling])
|
||||
|
||||
// overleaf-lab: load the admin-defined rubrics once on mount. On any failure
|
||||
// we still mark the list as loaded (with an empty array) so the pane can show
|
||||
// the "no rubrics configured" message instead of spinning forever.
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
async function fetchRubrics() {
|
||||
if (!projectId) {
|
||||
setRubrics([])
|
||||
setRubricsLoaded(true)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/project/${projectId}/llm/compliance/rubrics`,
|
||||
{ credentials: 'same-origin' }
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`[LLMCompliance] Rubrics endpoint returned ${response.status}`
|
||||
)
|
||||
}
|
||||
const data: RubricsResponse = await response.json()
|
||||
if (cancelled) return
|
||||
|
||||
const loadedRubrics = data.rubrics || []
|
||||
setRubrics(loadedRubrics)
|
||||
setSelectedRubricId(loadedRubrics[0]?.id || '')
|
||||
setRubricsLoaded(true)
|
||||
} catch (err) {
|
||||
console.error('[LLMCompliance] Failed to fetch rubrics:', err)
|
||||
if (cancelled) return
|
||||
setRubrics([])
|
||||
setRubricsLoaded(true)
|
||||
}
|
||||
}
|
||||
|
||||
fetchRubrics()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
// overleaf-lab: one poll tick. Updates phase/position/result/errorInfo and
|
||||
// stops polling on any terminal state (done/error/cancelled/not_found).
|
||||
const pollOnce = useCallback(
|
||||
async (jobId: string) => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/project/${projectId}/llm/compliance/status/${jobId}`,
|
||||
{ credentials: 'same-origin' }
|
||||
)
|
||||
const json = await response.json()
|
||||
if (!mountedRef.current) return
|
||||
|
||||
if (!json.ok) {
|
||||
// Missing, foreign, or expired job.
|
||||
stopPolling()
|
||||
jobIdRef.current = null
|
||||
setErrorInfo({
|
||||
errorCode: 'not_found',
|
||||
message: json.message || 'Review not found or expired',
|
||||
})
|
||||
setPhase('error')
|
||||
return
|
||||
}
|
||||
|
||||
switch (json.status) {
|
||||
case 'queued':
|
||||
setPhase('queued')
|
||||
setPosition(typeof json.position === 'number' ? json.position : 0)
|
||||
setProgress(null)
|
||||
break
|
||||
case 'running':
|
||||
setPhase('running')
|
||||
if (
|
||||
typeof json.passesTotal === 'number' &&
|
||||
json.passesTotal > 0
|
||||
) {
|
||||
setProgress({
|
||||
phase:
|
||||
json.phase === 'summarizing'
|
||||
? 'summarizing'
|
||||
: 'checking',
|
||||
passesDone:
|
||||
typeof json.passesDone === 'number'
|
||||
? json.passesDone
|
||||
: 0,
|
||||
passesTotal: json.passesTotal,
|
||||
currentRequirement:
|
||||
typeof json.currentRequirement === 'string'
|
||||
? json.currentRequirement
|
||||
: '',
|
||||
elapsedMs:
|
||||
typeof json.elapsedMs === 'number' ? json.elapsedMs : 0,
|
||||
})
|
||||
} else {
|
||||
// Still assembling the document: the rubric is not split yet.
|
||||
setProgress({
|
||||
phase: 'preparing',
|
||||
passesDone: 0,
|
||||
passesTotal: 0,
|
||||
currentRequirement: '',
|
||||
elapsedMs: 0,
|
||||
})
|
||||
}
|
||||
break
|
||||
case 'done':
|
||||
stopPolling()
|
||||
jobIdRef.current = null
|
||||
setResult(json.result as ComplianceResult)
|
||||
setErrorInfo(null)
|
||||
setProgress(null)
|
||||
setPhase('done')
|
||||
break
|
||||
case 'error':
|
||||
stopPolling()
|
||||
jobIdRef.current = null
|
||||
setErrorInfo({
|
||||
errorCode: json.errorCode,
|
||||
message: json.message,
|
||||
documentTokensEstimate: json.documentTokensEstimate,
|
||||
maxContextTokens: json.maxContextTokens,
|
||||
reviewMaxTokens: json.reviewMaxTokens,
|
||||
})
|
||||
setProgress(null)
|
||||
setPhase('error')
|
||||
break
|
||||
case 'cancelled':
|
||||
stopPolling()
|
||||
jobIdRef.current = null
|
||||
setProgress(null)
|
||||
setPhase('idle')
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
} catch (err) {
|
||||
// overleaf-lab: a transient network error should not kill the poll;
|
||||
// keep the interval and try again on the next tick.
|
||||
console.error('[LLMCompliance] Status poll failed:', err)
|
||||
}
|
||||
},
|
||||
[projectId, stopPolling]
|
||||
)
|
||||
|
||||
const startPolling = useCallback(
|
||||
(jobId: string) => {
|
||||
stopPolling()
|
||||
pollRef.current = window.setInterval(() => {
|
||||
pollOnce(jobId)
|
||||
}, 2000)
|
||||
},
|
||||
[pollOnce, stopPolling]
|
||||
)
|
||||
|
||||
const runReview = useCallback(async () => {
|
||||
if (!selectedRubricId) return
|
||||
|
||||
setResult(null)
|
||||
setErrorInfo(null)
|
||||
setPosition(0)
|
||||
setProgress(null)
|
||||
|
||||
try {
|
||||
const csrfToken = getMeta('ol-csrfToken')
|
||||
const response = await fetch(
|
||||
`/project/${projectId}/llm/compliance/start`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrfToken,
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ rubricId: selectedRubricId }),
|
||||
}
|
||||
)
|
||||
|
||||
const json = await response.json()
|
||||
if (!mountedRef.current) return
|
||||
|
||||
if (json.ok) {
|
||||
jobIdRef.current = json.jobId
|
||||
const startPhase: CompliancePhase =
|
||||
json.status === 'running' ? 'running' : 'queued'
|
||||
setPhase(startPhase)
|
||||
setPosition(typeof json.position === 'number' ? json.position : 0)
|
||||
startPolling(json.jobId)
|
||||
} else {
|
||||
setErrorInfo({ errorCode: json.error, message: json.message })
|
||||
setPhase('error')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[LLMCompliance] Start review request failed:', err)
|
||||
if (!mountedRef.current) return
|
||||
setErrorInfo({ errorCode: 'failed', message: 'Request failed' })
|
||||
setPhase('error')
|
||||
}
|
||||
}, [projectId, selectedRubricId, startPolling])
|
||||
|
||||
const cancelReview = useCallback(async () => {
|
||||
const jobId = jobIdRef.current
|
||||
stopPolling()
|
||||
setPhase('idle')
|
||||
setPosition(0)
|
||||
setProgress(null)
|
||||
jobIdRef.current = null
|
||||
if (!jobId) return
|
||||
|
||||
try {
|
||||
const csrfToken = getMeta('ol-csrfToken')
|
||||
await fetch(`/project/${projectId}/llm/compliance/cancel/${jobId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrfToken },
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('[LLMCompliance] Cancel request failed:', err)
|
||||
}
|
||||
}, [projectId, stopPolling])
|
||||
|
||||
// overleaf-lab: best-effort cancel on page refresh/close. keepalive lets the
|
||||
// request survive the unload. Only fire when a job is actually active. We do
|
||||
// NOT cancel on a normal unmount (a tab switch just hides the pane).
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
const jobId = jobIdRef.current
|
||||
const currentPhase = phaseRef.current
|
||||
if (
|
||||
jobId &&
|
||||
(currentPhase === 'queued' || currentPhase === 'running')
|
||||
) {
|
||||
const csrfToken = getMeta('ol-csrfToken')
|
||||
fetch(`/project/${projectId}/llm/compliance/cancel/${jobId}`, {
|
||||
method: 'POST',
|
||||
keepalive: true,
|
||||
credentials: 'same-origin',
|
||||
headers: { 'X-CSRF-Token': csrfToken },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('beforeunload', handler)
|
||||
return () => {
|
||||
window.removeEventListener('beforeunload', handler)
|
||||
}
|
||||
}, [projectId])
|
||||
|
||||
// overleaf-lab: build a self-contained HTML report and trigger a client-side
|
||||
// download. Open it in a browser and Print to PDF for a PDF copy.
|
||||
const downloadReport = useCallback(() => {
|
||||
if (!result) return
|
||||
|
||||
const html = buildReportHtml(result)
|
||||
const safeRubricName =
|
||||
(result.rubric?.name || 'review')
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'review'
|
||||
|
||||
const now = new Date()
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(
|
||||
now.getDate()
|
||||
)}-${pad(now.getHours())}${pad(now.getMinutes())}`
|
||||
const filename = `review-${safeRubricName}-${stamp}.html`
|
||||
|
||||
const blob = new Blob([html], {
|
||||
type: 'text/html;charset=utf-8',
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = filename
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
document.body.removeChild(anchor)
|
||||
URL.revokeObjectURL(url)
|
||||
}, [result])
|
||||
|
||||
return {
|
||||
rubrics,
|
||||
rubricsLoaded,
|
||||
hasRubrics: rubrics.length > 0,
|
||||
selectedRubricId,
|
||||
setSelectedRubricId,
|
||||
phase,
|
||||
position,
|
||||
progress,
|
||||
result,
|
||||
errorInfo,
|
||||
runReview,
|
||||
cancelReview,
|
||||
downloadReport,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
// overleaf-lab: shared client-side view of the super-admin LLM feature flags
|
||||
// (chat / inline completion / compliance review) for the CURRENT project. The
|
||||
// backend is the source of truth and enforces every flag; the frontend only uses
|
||||
// this to hide UI cleanly. The flags are fetched once and cached at module level
|
||||
// so every caller (rail pane, selection toolbar, ...) shares a single request
|
||||
// instead of each refetching.
|
||||
|
||||
export type Features = {
|
||||
chatEnabled: boolean
|
||||
completionEnabled: boolean
|
||||
reviewEnabled: boolean
|
||||
allowUserSettings: boolean
|
||||
loaded: boolean
|
||||
}
|
||||
|
||||
// overleaf-lab: fail-open value used before the flags load and whenever the
|
||||
// fetch fails. Features read as enabled; the backend still refuses disabled ones.
|
||||
const ALL_ENABLED: Features = {
|
||||
chatEnabled: true,
|
||||
completionEnabled: true,
|
||||
reviewEnabled: true,
|
||||
allowUserSettings: false,
|
||||
loaded: true,
|
||||
}
|
||||
|
||||
// overleaf-lab: module-level cache + in-flight promise dedupe the fetch across
|
||||
// every hook instance mounted in the same page.
|
||||
let cache: Features | null = null
|
||||
let inflight: Promise<Features> | null = null
|
||||
|
||||
function fetchFeatures(): Promise<Features> {
|
||||
if (inflight) return inflight
|
||||
|
||||
const projectId = getMeta('ol-project_id')
|
||||
if (!projectId) {
|
||||
// overleaf-lab: no project context (e.g. outside the editor). Treat every
|
||||
// feature as enabled and cache it so callers stop waiting.
|
||||
cache = { ...ALL_ENABLED }
|
||||
inflight = Promise.resolve(cache)
|
||||
return inflight
|
||||
}
|
||||
|
||||
inflight = fetch(`/project/${projectId}/llm/features`, {
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
.then(resp => {
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
return resp.json()
|
||||
})
|
||||
.then((data: any) => {
|
||||
// overleaf-lab: every flag defaults to true when absent.
|
||||
const features: Features = {
|
||||
chatEnabled: data?.chatEnabled !== false,
|
||||
completionEnabled: data?.completionEnabled !== false,
|
||||
reviewEnabled: data?.reviewEnabled !== false,
|
||||
allowUserSettings: data?.allowUserSettings === true,
|
||||
loaded: true,
|
||||
}
|
||||
cache = features
|
||||
return features
|
||||
})
|
||||
.catch(err => {
|
||||
// overleaf-lab: fail open on the client; the backend still enforces.
|
||||
console.error('[LLMFeatures] Failed to fetch feature flags:', err)
|
||||
cache = { ...ALL_ENABLED }
|
||||
return cache
|
||||
})
|
||||
|
||||
return inflight
|
||||
}
|
||||
|
||||
export function useLLMFeatures(): Features {
|
||||
const [features, setFeatures] = useState<Features>(
|
||||
cache ?? {
|
||||
chatEnabled: true,
|
||||
completionEnabled: true,
|
||||
reviewEnabled: true,
|
||||
allowUserSettings: false,
|
||||
loaded: cache ? true : false,
|
||||
}
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
if (cache) {
|
||||
// overleaf-lab: already resolved earlier; adopt it.
|
||||
setFeatures(cache)
|
||||
return
|
||||
}
|
||||
|
||||
fetchFeatures().then(result => {
|
||||
// overleaf-lab: guard against setState after unmount.
|
||||
if (!cancelled) setFeatures(result)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return features
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
// overleaf-lab: shared client-side view of the admin-editable LLM prompts
|
||||
// (Ask-AI system prompt, error prompt, per-action transform templates) for the
|
||||
// CURRENT project. The backend is the source of truth; the frontend uses these
|
||||
// only to override its hardcoded fallbacks when an admin has customized them.
|
||||
// The prompts are fetched once and cached at module level so every caller
|
||||
// (selection toolbar, error "Ask AI" button, ...) shares a single request
|
||||
// instead of each refetching.
|
||||
|
||||
export type Prompts = {
|
||||
askAiSystemPrompt?: string
|
||||
errorPrompt?: string
|
||||
askAiActionPrompts?: Record<string, string>
|
||||
}
|
||||
|
||||
export type PromptsState = {
|
||||
// overleaf-lab: `null` means "no admin prompts available" (no project
|
||||
// context, fetch failed, or empty payload). Callers then use their own
|
||||
// hardcoded fallback verbatim.
|
||||
prompts: Prompts | null
|
||||
loaded: boolean
|
||||
}
|
||||
|
||||
// overleaf-lab: value used when there is no project context or the fetch fails.
|
||||
// Callers keep their hardcoded prompts as the effective fallback.
|
||||
const NO_PROMPTS: PromptsState = {
|
||||
prompts: null,
|
||||
loaded: true,
|
||||
}
|
||||
|
||||
// overleaf-lab: module-level cache + in-flight promise dedupe the fetch across
|
||||
// every hook instance mounted in the same page.
|
||||
let cache: PromptsState | null = null
|
||||
let inflight: Promise<PromptsState> | null = null
|
||||
|
||||
function fetchPrompts(): Promise<PromptsState> {
|
||||
if (inflight) return inflight
|
||||
|
||||
const projectId = getMeta('ol-project_id')
|
||||
if (!projectId) {
|
||||
// overleaf-lab: no project context (e.g. outside the editor). Cache the
|
||||
// empty result so callers stop waiting and use their own fallback.
|
||||
cache = { ...NO_PROMPTS }
|
||||
inflight = Promise.resolve(cache)
|
||||
return inflight
|
||||
}
|
||||
|
||||
inflight = fetch(`/project/${projectId}/llm/prompts`, {
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
.then(resp => {
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
return resp.json()
|
||||
})
|
||||
.then((data: any) => {
|
||||
// overleaf-lab: keep only well-typed fields; anything missing stays
|
||||
// undefined so callers fall back to their hardcoded value per field.
|
||||
const prompts: Prompts = {
|
||||
askAiSystemPrompt:
|
||||
typeof data?.askAiSystemPrompt === 'string'
|
||||
? data.askAiSystemPrompt
|
||||
: undefined,
|
||||
errorPrompt:
|
||||
typeof data?.errorPrompt === 'string'
|
||||
? data.errorPrompt
|
||||
: undefined,
|
||||
askAiActionPrompts:
|
||||
data?.askAiActionPrompts &&
|
||||
typeof data.askAiActionPrompts === 'object'
|
||||
? (data.askAiActionPrompts as Record<string, string>)
|
||||
: undefined,
|
||||
}
|
||||
const state: PromptsState = { prompts, loaded: true }
|
||||
cache = state
|
||||
return state
|
||||
})
|
||||
.catch(err => {
|
||||
// overleaf-lab: on failure, callers keep their hardcoded prompts.
|
||||
console.error('[LLMPrompts] Failed to fetch prompts:', err)
|
||||
cache = { ...NO_PROMPTS }
|
||||
return cache
|
||||
})
|
||||
|
||||
return inflight
|
||||
}
|
||||
|
||||
export function useLLMPrompts(): PromptsState {
|
||||
const [state, setState] = useState<PromptsState>(
|
||||
cache ?? { prompts: null, loaded: false }
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
if (cache) {
|
||||
// overleaf-lab: already resolved earlier; adopt it.
|
||||
setState(cache)
|
||||
return
|
||||
}
|
||||
|
||||
fetchPrompts().then(result => {
|
||||
// overleaf-lab: guard against setState after unmount.
|
||||
if (!cancelled) setState(result)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return state
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// overleaf-lab: tiny cross-component store for the chat's currently selected
|
||||
// model id. The chat panel (use-llm-chat) and the selection toolbar / "Ask AI"
|
||||
// (llm-toolbar) live in separate React trees, so a plain module value would not
|
||||
// survive between them. localStorage bridges the two: the chat writes the chosen
|
||||
// model, the toolbar reads it so "Ask AI" uses the same model as the chat.
|
||||
const STORAGE_KEY = 'llm.chat.selectedModel'
|
||||
|
||||
export function readSelectedModel(): string {
|
||||
try {
|
||||
return window.localStorage.getItem(STORAGE_KEY) || ''
|
||||
} catch {
|
||||
return '' // storage disabled / private mode: fall back to server default
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSelectedModel(modelId: string): void {
|
||||
try {
|
||||
if (modelId) {
|
||||
window.localStorage.setItem(STORAGE_KEY, modelId)
|
||||
} else {
|
||||
window.localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
} catch {
|
||||
// ignore: storage disabled / private mode
|
||||
}
|
||||
}
|
||||
@@ -1,58 +1,40 @@
|
||||
// LLM Chat styling matching the regular chat theme
|
||||
// overleaf-lab: theme-aware LLM chat palette. The original code forced a dark
|
||||
// palette (var(--bg-dark-primary), var(--white), ...) even on the light theme,
|
||||
// so the chat looked dark on a light IDE. We now use the theme-adaptive tokens
|
||||
// (--content-primary, --border-primary) and a transparent background that simply
|
||||
// inherits the already-themed rail panel, plus translucent grey bubbles/code that
|
||||
// read well on both light and dark. Fallbacks keep it safe if a token is missing:
|
||||
// none of them forces a dark background.
|
||||
: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-chat-bg: transparent;
|
||||
--llm-chat-color: var(--content-primary-themed, inherit);
|
||||
--llm-chat-text-color: var(--content-primary-themed, inherit);
|
||||
--llm-chat-instructions-color: var(--content-secondary, #6c757d);
|
||||
--llm-chat-new-message-bg: transparent;
|
||||
--llm-chat-new-message-textarea-color: var(--content-primary-themed, inherit);
|
||||
--llm-chat-new-message-textarea-bg: var(--bg-primary, transparent);
|
||||
--llm-chat-new-message-textarea-border: var(--border-primary, rgba(125, 125, 125, 0.35));
|
||||
--llm-chat-new-message-border: var(--border-primary, rgba(125, 125, 125, 0.2));
|
||||
--llm-chat-message-user-bg: var(--green-70, #2b8a5f);
|
||||
--llm-chat-message-user-text: var(--white, #fff);
|
||||
--llm-chat-message-assistant-bg: rgba(125, 125, 125, 0.14);
|
||||
--llm-chat-message-assistant-text: var(--content-primary-themed, inherit);
|
||||
--llm-chat-code-bg: rgba(125, 125, 125, 0.18);
|
||||
--llm-chat-code-text: var(--content-primary-themed, inherit);
|
||||
|
||||
/* 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);
|
||||
--llm-chat-suggestions-bg: rgba(125, 125, 125, 0.1);
|
||||
--llm-chat-suggestions-color: var(--content-primary-themed, inherit);
|
||||
--llm-chat-suggestions-title-color: var(--content-primary-themed, inherit);
|
||||
--llm-chat-suggestion-item-bg: var(--bg-primary, transparent);
|
||||
--llm-chat-suggestion-item-color: var(--content-primary-themed, inherit);
|
||||
--llm-model-selector-label-color: var(--content-primary-themed, inherit);
|
||||
--llm-model-selector-select-color: var(--content-primary-themed, inherit);
|
||||
/* 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;
|
||||
@@ -97,7 +79,10 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
align-items: center;
|
||||
/* overleaf-lab: stretch children to the full panel width so the input box and
|
||||
messages grow as the panel widens (was center, which pinned them to their
|
||||
content width and left the input small). */
|
||||
align-items: stretch;
|
||||
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);
|
||||
@@ -138,7 +123,10 @@
|
||||
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 */
|
||||
/* overleaf-lab: use the ADAPTIVE themed token so the CLOSED select (the
|
||||
selected model name) is legible on light AND dark. Plain --content-primary
|
||||
did not flip on dark; --content-primary-themed does (the "Model" label
|
||||
already uses it). The option popup is themed separately below. */
|
||||
color: var(--content-primary-themed);
|
||||
font-size: var(--font-size-02);
|
||||
outline: none;
|
||||
@@ -154,6 +142,15 @@
|
||||
background-size: 6px 6px, 6px 6px;
|
||||
background-repeat: no-repeat;
|
||||
|
||||
/* overleaf-lab: theme the native dropdown popup. Field/FieldText are a
|
||||
matched CSS system-color pair, so the option list stays readable in both
|
||||
light and dark, independent of how the app signals its theme (this fixes
|
||||
the white-background / light-grey-text popup on the dark theme). */
|
||||
option {
|
||||
background-color: Field;
|
||||
color: FieldText;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--border-primary);
|
||||
}
|
||||
|
||||
@@ -27,9 +27,10 @@ if (llmEnabled) {
|
||||
// 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)
|
||||
// overleaf-lab: make LLM_ALLOW_USER_SETTINGS authoritative. Upstream's `?? true`
|
||||
// fallback meant LLM_ALLOW_USER_SETTINGS=false never actually disabled per-user
|
||||
// settings; now only an explicit 'true' enables bring-your-own keys (OpenAI, etc.).
|
||||
Settings.llm.allowUserSettings = process.env.LLM_ALLOW_USER_SETTINGS === 'true'
|
||||
|
||||
logger.info(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user