From e7ddd0ea61d766955538efad141134f8c6f13e49 Mon Sep 17 00:00:00 2001 From: yu-i-i Date: Mon, 27 Jul 2026 15:49:00 +0200 Subject: [PATCH] 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. --- services/web/app/src/models/User.mjs | 1 + .../components/review-tooltip-menu.tsx | 4 +- .../llm/app/src/LLMAdminController.mjs | 370 +++- .../modules/llm/app/src/LLMChatController.mjs | 329 +++- .../llm/app/src/LLMComplianceController.mjs | 1649 +++++++++++++++++ .../web/modules/llm/app/src/LLMCrypto.mjs | 89 + .../web/modules/llm/app/src/LLMPrompts.mjs | 80 + .../web/modules/llm/app/src/LLMRouter.mjs | 64 +- .../llm/app/src/LLMSettingsController.mjs | 143 +- .../llm/app/views/llm-admin-settings.pug | 16 + .../modules/llm/app/views/llm-settings.pug | 2 + .../js/components/llm-admin-settings-page.tsx | 538 +++++- .../js/components/llm-compliance-pane.tsx | 519 ++++++ .../frontend/js/components/llm-rail-pane.tsx | 136 +- .../js/components/llm-settings-section.tsx | 310 +++- .../llm-source-editor-component.tsx | 15 +- .../frontend/js/components/llm-toolbar.tsx | 120 +- .../pdf-log-entry-ask-ai-button.tsx | 78 +- .../llm/frontend/js/hooks/use-llm-chat.ts | 20 +- .../frontend/js/hooks/use-llm-compliance.ts | 529 ++++++ .../llm/frontend/js/hooks/use-llm-features.ts | 106 ++ .../llm/frontend/js/hooks/use-llm-prompts.ts | 114 ++ .../frontend/js/utils/llm-selected-model.ts | 26 + .../llm/frontend/stylesheets/llm-chat.scss | 95 +- services/web/modules/llm/index.mjs | 7 +- 25 files changed, 5134 insertions(+), 226 deletions(-) create mode 100644 services/web/modules/llm/app/src/LLMComplianceController.mjs create mode 100644 services/web/modules/llm/app/src/LLMCrypto.mjs create mode 100644 services/web/modules/llm/app/src/LLMPrompts.mjs create mode 100644 services/web/modules/llm/frontend/js/components/llm-compliance-pane.tsx create mode 100644 services/web/modules/llm/frontend/js/hooks/use-llm-compliance.ts create mode 100644 services/web/modules/llm/frontend/js/hooks/use-llm-features.ts create mode 100644 services/web/modules/llm/frontend/js/hooks/use-llm-prompts.ts create mode 100644 services/web/modules/llm/frontend/js/utils/llm-selected-model.ts diff --git a/services/web/app/src/models/User.mjs b/services/web/app/src/models/User.mjs index 4de8e68af5..13aa4c817f 100644 --- a/services/web/app/src/models/User.mjs +++ b/services/web/app/src/models/User.mjs @@ -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 } ) diff --git a/services/web/frontend/js/features/review-panel/components/review-tooltip-menu.tsx b/services/web/frontend/js/features/review-panel/components/review-tooltip-menu.tsx index 6a7fa30157..2e3ce6b21b 100644 --- a/services/web/frontend/js/features/review-panel/components/review-tooltip-menu.tsx +++ b/services/web/frontend/js/features/review-panel/components/review-tooltip-menu.tsx @@ -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: () => {t('add_comment')} - {llmEnabled && ( + {llmEnabled && llmChatEnabled && ( <>
+ ) +} + 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( (getMeta('ol-systemPrompt') as string) || DEFAULT_SYSTEM_PROMPT @@ -125,6 +178,36 @@ export default function LLMAdminSettingsPage() { .filter(Boolean) ) const [availableModels, setAvailableModels] = useState([]) + // 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( + (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>(initialRubrics) + const [reviewModel, setReviewModel] = useState((getMeta('ol-reviewModel') as string) || '') + const [maxContextTokens, setMaxContextTokens] = useState(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(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(getMeta('ol-chatEnabled') !== false) + const [completionEnabled, setCompletionEnabled] = useState(getMeta('ol-completionEnabled') !== false) + const [reviewEnabled, setReviewEnabled] = useState(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((getMeta('ol-askAiSystemPrompt') as string) || '') + const [errorPrompt, setErrorPrompt] = useState((getMeta('ol-errorPrompt') as string) || '') + const [reviewSystemPrompt, setReviewSystemPrompt] = useState((getMeta('ol-reviewSystemPrompt') as string) || '') + const initialActions = (getMeta('ol-askAiActionPrompts') as Record) || {} + const [askAiActionPrompts, setAskAiActionPrompts] = useState>(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(null) const [testStatus, setTestStatus] = useState(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() {
- {/* โ”€โ”€ Section 1: API Connection โ”€โ”€ */} + {/* โ”€โ”€ Section 1: Features โ”€โ”€ */} + {/* overleaf-lab: master on/off switches per AI feature */}
1 + + {t('llm_features', 'Features')} +
+

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

+ +
+ {/* overleaf-lab: one toggle switch per feature */} + {[ + { key: 'chat', on: chatEnabled, set: setChatEnabled, title: t('feature_chat', 'Chat'), help: t('feature_chat_help', 'The AI chat panel and Ask AI on selection.') }, + { key: 'completion', on: completionEnabled, set: setCompletionEnabled, title: t('feature_completion', 'Inline completion'), help: t('feature_completion_help', 'Autocomplete suggestions while typing.') }, + { key: 'review', on: reviewEnabled, set: setReviewEnabled, title: t('feature_review', 'Compliance review'), help: t('feature_review_help', 'The whole-document review.') }, + ].map((f, i, arr) => ( +
+
+ {f.title} + {f.help} +
+ +
+ ))} +
+
+ + {/* โ”€โ”€ Section 2: API Connection โ”€โ”€ */} +
+
+ 2 {t('api_connection', 'API Connection')} {testStatus === 'success' && ( @@ -264,6 +426,12 @@ export default function LLMAdminSettingsPage() { } placeholder="https://api.example.com/v1" /> + {apiUrlFromEnv && ( + + + {t('llm_admin_from_env', 'Inherited from the LLM_API_URL environment variable. Saving here stores it in the admin settings file.')} + + )} @@ -288,6 +456,10 @@ export default function LLMAdminSettingsPage() { {t('llm_api_key_stored', 'An API key is already stored. Leave blank to keep it.')} )} + + + {t('llm_api_key_optional_local', 'Leave blank for a local server with no auth (e.g. a llama.cpp server).')} +
@@ -317,10 +489,10 @@ export default function LLMAdminSettingsPage() {
- {/* โ”€โ”€ Section 2: Model Selection โ”€โ”€ */} + {/* โ”€โ”€ Section 3: Model Selection โ”€โ”€ */}
- 2 + 3 {t('model_selection', 'Model Selection')} {allModels.length > 0 && ( @@ -427,12 +599,44 @@ export default function LLMAdminSettingsPage() {
)} + + {/* overleaf-lab: admin picks the single shared inline-completion model */} + 0 ? '1rem' : 0 }}> + + {t('inline_completion_model', 'Inline completion model')} + + + + {t( + 'inline_completion_model_admin_help', + 'Model used for inline autocomplete on the shared backend. Can differ from the chat models. Set to Disabled to turn off shared autocomplete (users with their own API key still get it).' + )} + +
- {/* โ”€โ”€ Section 3: System Prompt โ”€โ”€ */} + {/* โ”€โ”€ Section 4: System Prompt โ”€โ”€ */}
- 3 + 4 {t('system_prompt', 'System Prompt')}
@@ -476,6 +680,328 @@ export default function LLMAdminSettingsPage() {
+ {/* โ”€โ”€ Section 5: Compliance Review โ”€โ”€ */} +
+
+ 5 + + {t('compliance_review', 'Compliance Review')} +
+

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

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

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

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

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

+ + {/* overleaf-lab: (a) the three standalone prompts, each with a reset link */} + {[ + { + key: 'askAiSystemPrompt', + value: askAiSystemPrompt, + set: setAskAiSystemPrompt, + def: promptDefaults.askAiSystemPrompt, + label: t('ask_ai_behavior_prompt', 'Ask AI behavior prompt'), + help: t('ask_ai_behavior_prompt_help', 'System prompt for the selection toolbar (Ask AI / paraphrase / rewrite).'), + }, + { + key: 'errorPrompt', + value: errorPrompt, + set: setErrorPrompt, + def: promptDefaults.errorPrompt, + label: t('error_help_prompt', 'Error help prompt'), + help: t('error_help_prompt_help', 'Instructions appended when a user clicks Ask AI about a compile error.'), + }, + { + key: 'reviewSystemPrompt', + value: reviewSystemPrompt, + set: setReviewSystemPrompt, + def: promptDefaults.reviewSystemPrompt, + label: t('review_system_prompt', 'Review system prompt'), + help: t('review_system_prompt_help', 'System prompt for the whole-document compliance review.'), + }, + ].map(field => ( +
+ + + {field.label} + + ) => + field.set(e.target.value) + } + style={{ fontFamily: 'monospace', fontSize: '0.8125rem' }} + /> + +
+ + {field.help} + + field.set(field.def || '')} + style={{ padding: 0, fontSize: '0.8125rem' }} + > + + {t('reset_to_default', 'Reset to default')} + +
+
+ ))} + + {/* overleaf-lab: (b) collapsible Ask AI action templates, one textarea per action */} +
+ setShowActions(v => !v)} + style={{ padding: 0, fontSize: '0.875rem' }} + > + + {t('ask_ai_action_templates', 'Ask AI action templates')} + + + {showActions && ( +
+ + {t( + 'ask_ai_action_help', + 'Each template runs on the selected text. Use {{selection}} where the selected text should be inserted; if omitted, it is appended.' + )} + + {['paraphrase', 'academic', 'concise', 'punchy', 'split', 'join', 'summarize', 'explain', 'title', 'abstract'].map(key => ( +
+ + + {t(`ask_ai_action_${key}`, key.charAt(0).toUpperCase() + key.slice(1))} + + ) => { + const value = e.target.value + setAskAiActionPrompts(prev => ({ ...prev, [key]: value })) + }} + style={{ fontFamily: 'monospace', fontSize: '0.8125rem' }} + /> + +
+ { + const def = promptDefaults.askAiActionPrompts?.[key] || '' + setAskAiActionPrompts(prev => ({ ...prev, [key]: def })) + }} + style={{ padding: 0, fontSize: '0.8125rem' }} + > + + {t('reset_to_default', 'Reset to default')} + +
+
+ ))} +
+ )} +
+
+ {/* โ”€โ”€ Notifications โ”€โ”€ */} {showSuccess && (
diff --git a/services/web/modules/llm/frontend/js/components/llm-compliance-pane.tsx b/services/web/modules/llm/frontend/js/components/llm-compliance-pane.tsx new file mode 100644 index 0000000000..cd03567a25 --- /dev/null +++ b/services/web/modules/llm/frontend/js/components/llm-compliance-pane.tsx @@ -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 ( +
+ +
+
{item.requirement}
+ {item.evidence && ( +
+ {t('evidence', 'Evidence')}: {item.evidence} +
+ )} + {item.suggestion && ( +
+ {t('suggestion', 'Suggestion')}: {item.suggestion} +
+ )} +
+
+ ) +} + +function LLMCompliancePane() { + const { t } = useTranslation() + const { + rubrics, + rubricsLoaded, + selectedRubricId, + setSelectedRubricId, + phase, + position, + progress, + result, + errorInfo, + runReview, + cancelReview, + downloadReport, + hasRubrics, + } = useLLMCompliance() + + if (!rubricsLoaded) { + return ( +
+ {t('loading', 'Loading')}... +
+ ) + } + + if (!hasRubrics) { + return ( +
+ {t( + 'compliance_no_rubrics', + 'No review rubrics have been configured. Ask your administrator to add one in the LLM settings.' + )} +
+ ) + } + + // 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 ( +
+
{message}
+ {showTokens && ( +
+ {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')} + + )} +
+ )} +
+ ) + } + + 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 + ) + + return ( +
+ {/* overleaf-lab: download the report as Markdown */} +
+ + {' '} + {t('download_report', 'Download report')} + +
+ + {/* overleaf-lab: nudge users to keep the report; a new review is a + heavy, minutes-long operation for the server. */} +
+ {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.' + )} +
+ + {/* overleaf-lab: compact counts summary */} +
+ + {t('status_ok', 'OK')}: {counts.ok || 0} + + + {t('status_partial', 'Partial')}: {counts.partial || 0} + + + {t('status_missing', 'Missing')}: {counts.missing || 0} + + + {t('status_na', 'N/A')}: {counts.na || 0} + +
+ + {/* Summary block (synthesized in a final pass; may be empty if that + best-effort call failed, in which case show nothing) */} + {result.summary && ( +
+ {result.summary} +
+ )} + +
+ {t('model_label', 'Model')}: {result.model} - ~ + {result.documentTokensEstimate} {t('tokens', 'tokens')} +
+ + {/* Scrollable requirements list */} +
+ {result.items.map((item, idx) => ( + + ))} +
+
+ ) + } + + return ( +
+ {/* Header row: rubric selector + run button */} +
+ + {showRunButton && ( + + {' '} + {t('run_review', 'Run review')} + + )} +
+ + {/* overleaf-lab: queued state - position note + cancel */} + {phase === 'queued' && ( +
+
+ + {t('review_queued', 'In queue')} +
+ {position > 0 && ( +
+ {t('review_queue_position', 'Requests ahead of you:')}{' '} + {position} +
+ )} +
+ + {t('cancel', 'Cancel')} + +
+
+ )} + + {/* 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' && ( +
+
+ + + {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...' + )} + +
+ + {/* overleaf-lab: the requirement being checked right now */} + {progress?.phase === 'checking' && progress.currentRequirement && ( +
+ {progress.currentRequirement} +
+ )} + + {progress && progress.passesTotal > 0 && ( + <> +
+
+
+
+ {formatDuration(progress.elapsedMs)} + {/* 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. */} + + {Math.min( + progress.passesDone + 1, + progress.passesTotal + )} + /{progress.passesTotal} + +
+ + )} + +
+ + {t('cancel', 'Cancel')} + +
+
+ )} + + {phase === 'error' && renderError()} + {phase === 'done' && renderResult()} +
+ ) +} + +export default LLMCompliancePane diff --git a/services/web/modules/llm/frontend/js/components/llm-rail-pane.tsx b/services/web/modules/llm/frontend/js/components/llm-rail-pane.tsx index f531370290..f83867c9f6 100644 --- a/services/web/modules/llm/frontend/js/components/llm-rail-pane.tsx +++ b/services/web/modules/llm/frontend/js/components/llm-rail-pane.tsx @@ -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 (
- + + {features.loaded && !chatVisible && !reviewVisible ? ( + // overleaf-lab: the admin disabled every AI feature for this + // project, so there is nothing to show. +
+ {t( + 'llm_all_features_disabled', + 'AI features are currently disabled by the administrator.' + )} +
+ ) : ( + <> + {/* overleaf-lab: only show the two-tab bar when BOTH panes are + available; with a single feature the tab bar is redundant. */} + {chatVisible && reviewVisible && ( +
+ + +
+ )} + + {/* 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 && ( +
+ +
+ )} + {reviewVisible && ( +
+ +
+ )} + + )}
) } diff --git a/services/web/modules/llm/frontend/js/components/llm-settings-section.tsx b/services/web/modules/llm/frontend/js/components/llm-settings-section.tsx index f28933954d..b74b789c55 100644 --- a/services/web/modules/llm/frontend/js/components/llm-settings-section.tsx +++ b/services/web/modules/llm/frontend/js/components/llm-settings-section.tsx @@ -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([]) + const [isScanning, setIsScanning] = useState(false) + const [scanError, setScanError] = useState(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 + ) => { + 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 ( <> @@ -144,6 +285,43 @@ export default function LLMSettingsSection({ initialSettings }: Props) { handleSaveLLMSettings() }} > + + + {t('llm_provider', 'Provider')} + + + + + + API URL + {provider === 'custom' ? ( + setLlmApiUrl(e.target.value)} + placeholder="e.g., https://api.openai.com/v1" + /> + ) : ( + + )} + + API Key - - Model Name - setLlmModelName(e.target.value)} - placeholder="e.g., gpt-4, claude-3" - /> + + + {t('scan_for_models', 'Scan for models')} + + {scannedModels.length > 0 && ( + + {t( + 'scan_found_n_models', + 'Found {{count}} models.' + ).replace('{{count}}', String(scannedModels.length))} + + )} - - API URL - setLlmApiUrl(e.target.value)} - placeholder="e.g., https://api.openai.com/v1" - /> + {scanError && ( + + + + )} + + {/* overleaf-lab: personal CHAT model UI, hidden when the admin + disabled the chat feature for this user. */} + {chatEnabled && ( + + + {t('chat_models', 'Chat models')} + + {chatModelOptions.length > 0 && ( + + )} + setLlmModelName(e.target.value)} + placeholder="e.g., gpt-4o, gpt-4.1" + style={{ marginTop: '0.5rem' }} + /> + + {t( + 'chat_models_help', + 'Pick one or more chat models (or type comma-separated ids). The first one is the default in the editor.' + )} + + + )} + + {/* overleaf-lab: personal inline-COMPLETION model UI, hidden when + the admin disabled the completion feature for this user. */} + {completionEnabled && ( + + + {t('inline_completion_model', 'Inline completion model')} + + + {showCustomCompletion && ( + setLlmCompletionModel(e.target.value)} + placeholder="e.g., gpt-4o-mini" + style={{ marginTop: '0.5rem' }} + /> + )} + + {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).' + )} + + + )} + + + + {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.' + )} + {connectionCheckResult && ( @@ -197,7 +470,6 @@ export default function LLMSettingsSection({ initialSettings }: Props) { disabled={ isCheckingConnection || !llmApiUrl || - (!llmApiKey && !llmHasApiKey) || !llmModelName } isLoading={isCheckingConnection} diff --git a/services/web/modules/llm/frontend/js/components/llm-source-editor-component.tsx b/services/web/modules/llm/frontend/js/components/llm-source-editor-component.tsx index 49fe888e88..e4c4b05b49 100644 --- a/services/web/modules/llm/frontend/js/components/llm-source-editor-component.tsx +++ b/services/web/modules/llm/frontend/js/components/llm-source-editor-component.tsx @@ -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(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 } diff --git a/services/web/modules/llm/frontend/js/components/llm-toolbar.tsx b/services/web/modules/llm/frontend/js/components/llm-toolbar.tsx index 03ed331177..33c1041a23 100644 --- a/services/web/modules/llm/frontend/js/components/llm-toolbar.tsx +++ b/services/web/modules/llm/frontend/js/components/llm-toolbar.tsx @@ -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((_, 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((_, ref) => { // Build a prompt based on the mode const modePrompts: Record = { 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 = { + 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((_, 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((_, 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 ( +
+ ) + } + return (
((_, 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)} diff --git a/services/web/modules/llm/frontend/js/components/pdf-log-entry-ask-ai-button.tsx b/services/web/modules/llm/frontend/js/components/pdf-log-entry-ask-ai-button.tsx index 95577ff345..173bd74653 100644 --- a/services/web/modules/llm/frontend/js/components/pdf-log-entry-ask-ai-button.tsx +++ b/services/web/modules/llm/frontend/js/components/pdf-log-entry-ask-ai-button.tsx @@ -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') } diff --git a/services/web/modules/llm/frontend/js/hooks/use-llm-chat.ts b/services/web/modules/llm/frontend/js/hooks/use-llm-chat.ts index ba4f788470..1755476c3a 100644 --- a/services/web/modules/llm/frontend/js/hooks/use-llm-chat.ts +++ b/services/web/modules/llm/frontend/js/hooks/use-llm-chat.ts @@ -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[] = [ diff --git a/services/web/modules/llm/frontend/js/hooks/use-llm-compliance.ts b/services/web/modules/llm/frontend/js/hooks/use-llm-compliance.ts new file mode 100644 index 0000000000..a823a75034 --- /dev/null +++ b/services/web/modules/llm/frontend/js/hooks/use-llm-compliance.ts @@ -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)[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 = { + 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 = { + ok: 'OK', + partial: 'Partial', + missing: 'Missing', + na: 'N/A', + } + const statusColor: Record = { + 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 + ? `
Evidence: ${escapeHtml(item.evidence)}
` + : '' + const suggestion = item.suggestion + ? `
Suggestion: ${escapeHtml(item.suggestion)}
` + : '' + return `
${label} ${escapeHtml( + item.requirement + )}
${evidence}${suggestion}
` + }) + .join('\n') + + const title = `Compliance review - ${escapeHtml(result.rubric.name)}` + + return ` +${title} + + +

${title}

+
Model: ${escapeHtml(result.model)} - about ${result.documentTokensEstimate} tokens
+
+ OK ${counts.ok || 0} + Partial ${counts.partial || 0} + Missing ${counts.missing || 0} + N/A ${counts.na || 0} +
+
${escapeHtml(result.summary)}
+

Requirements

+ ${itemsHtml} +

Tip: use your browser's Print dialog to save this report as PDF.

+` +} + +export const useLLMCompliance = () => { + const projectId = getMeta('ol-project_id') + + const [rubrics, setRubrics] = useState([]) + const [rubricsLoaded, setRubricsLoaded] = useState(false) + const [selectedRubricId, setSelectedRubricId] = useState('') + const [phase, setPhase] = useState('idle') + const [position, setPosition] = useState(0) + const [result, setResult] = useState(null) + const [errorInfo, setErrorInfo] = useState(null) + const [progress, setProgress] = useState(null) + + // overleaf-lab: refs so async callbacks and the unload handler always see the + // current values without re-subscribing. + const jobIdRef = useRef(null) + const phaseRef = useRef('idle') + const pollRef = useRef(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, + } +} diff --git a/services/web/modules/llm/frontend/js/hooks/use-llm-features.ts b/services/web/modules/llm/frontend/js/hooks/use-llm-features.ts new file mode 100644 index 0000000000..70dc37c76b --- /dev/null +++ b/services/web/modules/llm/frontend/js/hooks/use-llm-features.ts @@ -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 | null = null + +function fetchFeatures(): Promise { + 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( + 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 +} diff --git a/services/web/modules/llm/frontend/js/hooks/use-llm-prompts.ts b/services/web/modules/llm/frontend/js/hooks/use-llm-prompts.ts new file mode 100644 index 0000000000..308906c180 --- /dev/null +++ b/services/web/modules/llm/frontend/js/hooks/use-llm-prompts.ts @@ -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 +} + +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 | null = null + +function fetchPrompts(): Promise { + 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) + : 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( + 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 +} diff --git a/services/web/modules/llm/frontend/js/utils/llm-selected-model.ts b/services/web/modules/llm/frontend/js/utils/llm-selected-model.ts new file mode 100644 index 0000000000..2fc8f4d17e --- /dev/null +++ b/services/web/modules/llm/frontend/js/utils/llm-selected-model.ts @@ -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 + } +} diff --git a/services/web/modules/llm/frontend/stylesheets/llm-chat.scss b/services/web/modules/llm/frontend/stylesheets/llm-chat.scss index 1d6cf3329f..618f76ddb6 100644 --- a/services/web/modules/llm/frontend/stylesheets/llm-chat.scss +++ b/services/web/modules/llm/frontend/stylesheets/llm-chat.scss @@ -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); } diff --git a/services/web/modules/llm/index.mjs b/services/web/modules/llm/index.mjs index 42a8393705..dbd53e50a4 100644 --- a/services/web/modules/llm/index.mjs +++ b/services/web/modules/llm/index.mjs @@ -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( {