mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-13 21:53:43 +01:00
LLM: Initial files
This commit is contained in:
@@ -59,7 +59,7 @@ async function loadManifest() {
|
||||
webpackManifest = {}
|
||||
}
|
||||
}
|
||||
function loadManifestFromWebpackDevServer(done = function () {}) {
|
||||
function loadManifestFromWebpackDevServer(done = function () { }) {
|
||||
fetchJson(new URL(`/manifest.json`, Settings.apis.webpack.url), {
|
||||
headers: {
|
||||
Host: 'localhost',
|
||||
@@ -422,6 +422,8 @@ export default async function (webRouter, privateApiRouter, publicApiRouter) {
|
||||
enablePandocConversions: Settings.enablePandocConversions,
|
||||
mixpanelLabsToken:
|
||||
Settings.labs?.enable && Settings.analytics?.mixpanel?.labsToken,
|
||||
llmAllowUserSettings: Settings.llm?.allowUserSettings ?? false,
|
||||
llmEnabled: Settings.llm?.enabled ?? false,
|
||||
}
|
||||
next()
|
||||
})
|
||||
|
||||
@@ -249,6 +249,11 @@ export const UserSchema = new Schema(
|
||||
us: { type: String },
|
||||
uk: { type: String },
|
||||
},
|
||||
// LLM module: per-user AI assistant settings
|
||||
useOwnLLMSettings: { type: Boolean, default: false },
|
||||
llmApiKey: { type: String, default: '' },
|
||||
llmModelName: { type: String, default: '' },
|
||||
llmApiUrl: { type: String, default: '' },
|
||||
},
|
||||
{ minimize: false }
|
||||
)
|
||||
|
||||
@@ -75,6 +75,8 @@ nav.navbar.navbar-default.navbar-main.navbar-expand-lg(
|
||||
+dropdown-menu-link-item(href='/admin/survey') Manage Surveys
|
||||
if canDisplayScriptLogMenu
|
||||
+dropdown-menu-link-item(href='/admin/script-logs') View Script Logs
|
||||
if settings.llm && settings.llm.enabled
|
||||
+dropdown-menu-link-item(href='/admin/llm/settings') LLM Settings
|
||||
|
||||
// loop over header_extras
|
||||
each item in nav.header_extras
|
||||
@@ -207,6 +209,8 @@ nav.navbar.navbar-default.navbar-main.navbar-expand-lg(
|
||||
.disabled.dropdown-item #{getSessionUser().email}
|
||||
+dropdown-menu-divider
|
||||
+dropdown-menu-link-item(href='/user/settings') #{translate('account_settings')}
|
||||
if settings.llm && settings.llm.allowUserSettings
|
||||
+dropdown-menu-link-item(href='/user/llm-settings') AI Settings
|
||||
if nav.showSubscriptionLink
|
||||
+dropdown-menu-link-item(href='/user/subscription') #{translate('subscription')}
|
||||
+dropdown-menu-divider
|
||||
|
||||
@@ -219,18 +219,16 @@ module.exports = {
|
||||
// options incase you want to run some services on remote hosts.
|
||||
apis: {
|
||||
web: {
|
||||
url: `http://${
|
||||
process.env.WEB_API_HOST || process.env.WEB_HOST || '127.0.0.1'
|
||||
}:${process.env.WEB_API_PORT || process.env.WEB_PORT || 3000}`,
|
||||
url: `http://${process.env.WEB_API_HOST || process.env.WEB_HOST || '127.0.0.1'
|
||||
}:${process.env.WEB_API_PORT || process.env.WEB_PORT || 3000}`,
|
||||
user: httpAuthUser,
|
||||
pass: httpAuthPass,
|
||||
},
|
||||
documentupdater: {
|
||||
url: `http://${
|
||||
process.env.DOCUPDATER_HOST ||
|
||||
url: `http://${process.env.DOCUPDATER_HOST ||
|
||||
process.env.DOCUMENT_UPDATER_HOST ||
|
||||
'127.0.0.1'
|
||||
}:3003`,
|
||||
}:3003`,
|
||||
},
|
||||
geoIpLookup: {
|
||||
cacheSize: intFromEnv('GEO_IP_LOOKUP_CACHE_SIZE', 10_000),
|
||||
@@ -298,8 +296,7 @@ module.exports = {
|
||||
v1_history: {
|
||||
url:
|
||||
process.env.V1_HISTORY_URL ||
|
||||
`http://${process.env.V1_HISTORY_HOST || '127.0.0.1'}:${
|
||||
process.env.V1_HISTORY_PORT || '3100'
|
||||
`http://${process.env.V1_HISTORY_HOST || '127.0.0.1'}:${process.env.V1_HISTORY_PORT || '3100'
|
||||
}/api`,
|
||||
urlForGitBridge: process.env.V1_HISTORY_URL_FOR_GIT_BRIDGE,
|
||||
user: process.env.V1_HISTORY_USER || 'staging',
|
||||
@@ -859,7 +856,7 @@ module.exports = {
|
||||
.filter(x => x !== ''),
|
||||
trustedUsersRegex: process.env.CAPTCHA_TRUSTED_USERS_REGEX
|
||||
? // Enforce matching of the entire input.
|
||||
new RegExp(`^${process.env.CAPTCHA_TRUSTED_USERS_REGEX}$`)
|
||||
new RegExp(`^${process.env.CAPTCHA_TRUSTED_USERS_REGEX}$`)
|
||||
: null,
|
||||
disabled: {
|
||||
invite: true,
|
||||
@@ -1063,10 +1060,25 @@ module.exports = {
|
||||
),
|
||||
],
|
||||
contactUsModal: [],
|
||||
sourceEditorExtensions: [],
|
||||
sourceEditorExtensions: [
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/llm/frontend/js/extensions/llm-inline-completion'
|
||||
),
|
||||
],
|
||||
sourceEditorVisualExtensions: [],
|
||||
sourceEditorComponents: [],
|
||||
pdfLogEntryHeaderActionComponents: [],
|
||||
sourceEditorComponents: [
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/llm/frontend/js/components/llm-source-editor-component'
|
||||
),
|
||||
],
|
||||
pdfLogEntryHeaderActionComponents: [
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/llm/frontend/js/components/pdf-log-entry-ask-ai-button'
|
||||
),
|
||||
],
|
||||
pdfLogEntryComponents: [],
|
||||
pdfLogEntriesComponents: [],
|
||||
pdfPreviewPromotions: [],
|
||||
@@ -1198,7 +1210,12 @@ module.exports = {
|
||||
'../modules/reference-picker/frontend/reference-index/advanced-reference-index.ts'
|
||||
),
|
||||
],
|
||||
railEntries: [],
|
||||
railEntries: [
|
||||
Path.resolve(
|
||||
__dirname,
|
||||
'../modules/llm/frontend/js/components/llm-rail-pane'
|
||||
),
|
||||
],
|
||||
railPopovers: [],
|
||||
railActions: [],
|
||||
railModals: [],
|
||||
@@ -1221,6 +1238,7 @@ module.exports = {
|
||||
'git-bridge',
|
||||
'github-sync',
|
||||
'zotero',
|
||||
'llm',
|
||||
],
|
||||
viewIncludes: {},
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ export type RailTabKey =
|
||||
| 'full-project-search'
|
||||
| 'dimensions'
|
||||
| 'workbench'
|
||||
| 'llm-chat'
|
||||
|
||||
export type RailModalKey =
|
||||
| 'keyboard-shortcuts'
|
||||
|
||||
@@ -39,6 +39,7 @@ import useEventListener from '@/shared/hooks/use-event-listener'
|
||||
import useReviewPanelLayout from '../hooks/use-review-panel-layout'
|
||||
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
|
||||
import { sendMB } from '@/infrastructure/event-tracking'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
const EDIT_MODE_SWITCH_WIDGET_HEIGHT = 40
|
||||
const CM_LINE_RIGHT_PADDING = 8
|
||||
@@ -127,13 +128,13 @@ const ReviewTooltipMenu: FC = () => {
|
||||
}
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<ReviewTooltipMenuContent onAddComment={addComment} />,
|
||||
<ReviewTooltipMenuContent onAddComment={addComment} onHide={() => setShow(false)} />,
|
||||
tooltipView.dom
|
||||
)
|
||||
}
|
||||
|
||||
const ReviewTooltipMenuContent = memo<{ onAddComment: () => void }>(
|
||||
function ReviewTooltipMenuContent({ onAddComment }) {
|
||||
const ReviewTooltipMenuContent = memo<{ onAddComment: () => void; onHide: () => void }>(
|
||||
function ReviewTooltipMenuContent({ onAddComment, onHide }) {
|
||||
const { t } = useTranslation()
|
||||
const view = useCodeMirrorViewContext()
|
||||
const state = useCodeMirrorStateContext()
|
||||
@@ -148,6 +149,8 @@ const ReviewTooltipMenuContent = memo<{ onAddComment: () => void }>(
|
||||
>()
|
||||
const [visible, setVisible] = useState(false)
|
||||
|
||||
const llmEnabled = !!(getMeta('ol-ExposedSettings') as any)?.llmEnabled
|
||||
|
||||
const handleAddCommentClick = useCallback(() => {
|
||||
sendMB('add-comment', {
|
||||
location: 'tooltip',
|
||||
@@ -271,6 +274,29 @@ const ReviewTooltipMenuContent = memo<{ onAddComment: () => void }>(
|
||||
<MaterialIcon type="chat" />
|
||||
{t('add_comment')}
|
||||
</button>
|
||||
{llmEnabled && (
|
||||
<>
|
||||
<div className="review-tooltip-menu-divider" />
|
||||
<button
|
||||
className="review-tooltip-menu-button"
|
||||
onClick={() => {
|
||||
const selectedText = view.state.sliceDoc(
|
||||
state.selection.main.from,
|
||||
state.selection.main.to
|
||||
)
|
||||
document.dispatchEvent(
|
||||
new CustomEvent('llm-ask-ai-selection', {
|
||||
detail: { text: selectedText },
|
||||
})
|
||||
)
|
||||
onHide()
|
||||
}}
|
||||
>
|
||||
<MaterialIcon type="smart_toy" />
|
||||
{t('ask_ai', 'Ask AI')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{showChangesButtons && (
|
||||
<>
|
||||
<div className="review-tooltip-menu-divider" />
|
||||
|
||||
@@ -32,6 +32,11 @@ export function AccountMenuItems({
|
||||
<NavDropdownLinkItem href="/user/settings">
|
||||
{t('account_settings')}
|
||||
</NavDropdownLinkItem>
|
||||
{getMeta('ol-ExposedSettings')?.llmAllowUserSettings ? (
|
||||
<NavDropdownLinkItem href="/user/llm-settings">
|
||||
{t('ai_settings', 'AI Settings')}
|
||||
</NavDropdownLinkItem>
|
||||
) : null}
|
||||
{showSubscriptionLink ? (
|
||||
<NavDropdownLinkItem href="/user/subscription">
|
||||
{t('subscription')}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { DefaultNavbarMetadata } from '@/shared/components/types/default-na
|
||||
import NavDropdownMenu from '@/shared/components/navbar/nav-dropdown-menu'
|
||||
import NavDropdownLinkItem from '@/shared/components/navbar/nav-dropdown-link-item'
|
||||
import { useSendProjectListMB } from '@/features/project-list/components/project-list-events'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
export default function AdminMenu({
|
||||
canDisplayAdminMenu,
|
||||
@@ -68,6 +69,11 @@ export default function AdminMenu({
|
||||
View Script Logs
|
||||
</NavDropdownLinkItem>
|
||||
) : null}
|
||||
{(getMeta('ol-ExposedSettings') as any)?.llmEnabled ? (
|
||||
<NavDropdownLinkItem href="/admin/llm/settings">
|
||||
LLM Settings
|
||||
</NavDropdownLinkItem>
|
||||
) : null}
|
||||
</NavDropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -160,8 +160,10 @@
|
||||
"ai_assist_in_overleaf_is_included_via_writefull_groups": "AI Assist in Overleaf is included as part of your group or organization’s Writefull subscription. To make changes you’ll need to speak to your subscription admin",
|
||||
"ai_assist_in_overleaf_is_included_via_writefull_individual": "AI Assist in Overleaf is included as part of your Writefull subscription. You can cancel or manage your access to AI Assist in your Writefull subscription settings.",
|
||||
"ai_assist_unavailable": "AI features are now included in every paid plan. See the <0>pricing page</0> for what each plan includes.",
|
||||
"ai_assist_unavailable_due_to_subscription_type": "We’re sorry—it looks like AI Assist isn’t available to you just yet due to your current subscription type.",
|
||||
"ai_assistance": "AI assistance",
|
||||
"ai_assistant": "AI Assistant",
|
||||
"ai_assistant_description": "Get help with LaTeX syntax, formatting, and troubleshooting",
|
||||
"ai_assistant_disabled": "AI Assistant disabled",
|
||||
"ai_assistant_explanation": "A LaTeX-fluent AI Assistant built into your editor.",
|
||||
"ai_can_make_mistakes": "AI can make mistakes. Review fixes before you apply them.",
|
||||
@@ -240,6 +242,7 @@
|
||||
"as_a_member_of_sso_required": "As a member of <b>__institutionName__</b>, you must log in to <b>__appName__</b> through your institution.",
|
||||
"as_email": "as __email__",
|
||||
"ascending": "Ascending",
|
||||
"ask_ai_about_error": "Ask AI about this error",
|
||||
"ask_proj_owner_to_add_you_as_github_collaborator": "You don’t have permission to push to the linked repository <0>__repoFullName__</0>. Ask the owner of the project (<1>__projectOwnerEmail__</1>) to add you as a collaborator to this repository.",
|
||||
"ask_proj_owner_to_export_to_github": "Ask the owner of the project (<0>__projectOwnerEmail__</0>) to export the project to a Git repository and add you as a collaborator.",
|
||||
"ask_proj_owner_to_unlink_from_current_github": "Ask the owner of the project (<0>__projectOwnerEmail__</0>) to unlink the project from the current git repository and create a connection to a different repository.",
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
import { expressify } from '@overleaf/promise-utils'
|
||||
|
||||
// Persist admin LLM settings in the same volume used by Overleaf data
|
||||
const ADMIN_SETTINGS_PATH = process.env.LLM_ADMIN_SETTINGS_PATH ||
|
||||
'/var/lib/overleaf/data/llm-admin-settings.json'
|
||||
|
||||
async function readAdminSettings() {
|
||||
try {
|
||||
const raw = await fs.readFile(ADMIN_SETTINGS_PATH, 'utf8')
|
||||
return JSON.parse(raw)
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') return {}
|
||||
logger.warn({ err, path: ADMIN_SETTINGS_PATH }, '[LLM] Could not read admin settings file')
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
async function writeAdminSettings(data) {
|
||||
try {
|
||||
await fs.mkdir(path.dirname(ADMIN_SETTINGS_PATH), { recursive: true })
|
||||
await fs.writeFile(ADMIN_SETTINGS_PATH, JSON.stringify(data, null, 2), 'utf8')
|
||||
} catch (err) {
|
||||
logger.error({ err, path: ADMIN_SETTINGS_PATH }, '[LLM] Could not write admin settings file')
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function adminSettingsPage(req, res) {
|
||||
const settings = await readAdminSettings()
|
||||
const pugPath = new URL('../../app/views/llm-admin-settings.pug', import.meta.url).pathname
|
||||
res.render(pugPath, {
|
||||
systemPrompt: settings.systemPrompt || '',
|
||||
llmApiUrl: settings.llmApiUrl || '',
|
||||
hasLlmApiKey: !!settings.llmApiKey,
|
||||
allowedModels: settings.allowedModels || [],
|
||||
})
|
||||
}
|
||||
|
||||
async function getAdminSettings(req, res) {
|
||||
const settings = await readAdminSettings()
|
||||
res.json({
|
||||
systemPrompt: settings.systemPrompt || '',
|
||||
llmApiUrl: settings.llmApiUrl || '',
|
||||
hasLlmApiKey: !!settings.llmApiKey,
|
||||
allowedModels: settings.allowedModels || [],
|
||||
})
|
||||
}
|
||||
|
||||
async function saveAdminSettings(req, res) {
|
||||
const { systemPrompt, llmApiUrl, llmApiKey, allowedModels } = req.body
|
||||
|
||||
if (typeof systemPrompt !== 'string') {
|
||||
return res.status(400).json({ error: 'systemPrompt must be a string' })
|
||||
}
|
||||
if (systemPrompt.length > 4000) {
|
||||
return res.status(400).json({ error: 'systemPrompt must be 4000 characters or fewer' })
|
||||
}
|
||||
|
||||
if (llmApiUrl && typeof llmApiUrl !== 'string') {
|
||||
return res.status(400).json({ error: 'llmApiUrl must be a string' })
|
||||
}
|
||||
if (llmApiKey && typeof llmApiKey !== 'string') {
|
||||
return res.status(400).json({ error: 'llmApiKey must be a string' })
|
||||
}
|
||||
if (allowedModels && !Array.isArray(allowedModels)) {
|
||||
return res.status(400).json({ error: 'allowedModels must be an array' })
|
||||
}
|
||||
|
||||
const existing = await readAdminSettings()
|
||||
const updatedSettings = {
|
||||
...existing,
|
||||
systemPrompt,
|
||||
llmApiUrl: typeof llmApiUrl === 'string' ? llmApiUrl : (existing.llmApiUrl || ''),
|
||||
allowedModels: Array.isArray(allowedModels) ? allowedModels : existing.allowedModels || [],
|
||||
}
|
||||
|
||||
if (typeof llmApiKey === 'string' && llmApiKey.trim().length > 0) {
|
||||
updatedSettings.llmApiKey = llmApiKey.trim()
|
||||
}
|
||||
|
||||
await writeAdminSettings(updatedSettings)
|
||||
logger.info({
|
||||
length: systemPrompt.length,
|
||||
llmApiUrl: !!updatedSettings.llmApiUrl,
|
||||
hasLlmApiKey: !!updatedSettings.llmApiKey,
|
||||
allowedModels: updatedSettings.allowedModels?.length || 0,
|
||||
}, '[LLM] Admin settings updated')
|
||||
|
||||
res.json({ success: true })
|
||||
}
|
||||
|
||||
// Exported so LLMChatController can prepend the admin system prompt
|
||||
export async function getSystemPrompt() {
|
||||
const settings = await readAdminSettings()
|
||||
return settings.systemPrompt || null
|
||||
}
|
||||
|
||||
export async function getAdminLLMSettings() {
|
||||
const settings = await readAdminSettings()
|
||||
return {
|
||||
llmApiUrl: settings.llmApiUrl || null,
|
||||
llmApiKey: settings.llmApiKey || null,
|
||||
allowedModels: Array.isArray(settings.allowedModels)
|
||||
? settings.allowedModels
|
||||
: [],
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAdminLLMConnection(req, res) {
|
||||
const { apiUrl, apiKey } = req.body
|
||||
const adminSettings = await getAdminLLMSettings()
|
||||
const effectiveUrl = apiUrl || adminSettings.llmApiUrl
|
||||
const effectiveKey = apiKey || adminSettings.llmApiKey
|
||||
|
||||
if (!effectiveUrl || !effectiveKey) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'LLM API URL and API key are required',
|
||||
})
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 30000)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${effectiveUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${effectiveKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'qwen3-32b',
|
||||
messages: [{ role: 'user', content: 'Test connection' }],
|
||||
max_tokens: 1,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
clearTimeout(timeout)
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text()
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'LLM connection failed',
|
||||
status: response.status,
|
||||
details: body,
|
||||
})
|
||||
}
|
||||
|
||||
res.json({ success: true, message: 'Connection successful' })
|
||||
} catch (err) {
|
||||
clearTimeout(timeout)
|
||||
if (err.name === 'AbortError') {
|
||||
return res.status(504).json({
|
||||
success: false,
|
||||
error: 'Connection timeout',
|
||||
})
|
||||
}
|
||||
logger.error({ err }, '[LLM] Admin connection check failed')
|
||||
res.status(500).json({ success: false, error: 'Connection attempt failed' })
|
||||
}
|
||||
}
|
||||
|
||||
async function scanAdminModels(req, res) {
|
||||
const { apiUrl, apiKey } = req.query
|
||||
const adminSettings = await getAdminLLMSettings()
|
||||
const llmApiUrl = apiUrl || adminSettings.llmApiUrl
|
||||
const llmApiKey = apiKey || adminSettings.llmApiKey
|
||||
|
||||
if (!llmApiUrl || !llmApiKey) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Admin LLM API URL and API key must be configured first',
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${llmApiUrl}/models`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${llmApiKey}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const body = await response.text()
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Failed to fetch models',
|
||||
status: response.status,
|
||||
details: body,
|
||||
})
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const ids = Array.isArray(data?.data)
|
||||
? data.data.map(entry => String(entry.id))
|
||||
: []
|
||||
|
||||
res.json({ success: true, models: ids })
|
||||
} catch (error) {
|
||||
logger.error({ err: error }, '[LLM] Admin model scan failed')
|
||||
res.status(500).json({ success: false, error: 'Model scan failed' })
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
adminSettingsPage: expressify(adminSettingsPage),
|
||||
getAdminSettings: expressify(getAdminSettings),
|
||||
saveAdminSettings: expressify(saveAdminSettings),
|
||||
checkAdminLLMConnection: expressify(checkAdminLLMConnection),
|
||||
scanAdminModels: expressify(scanAdminModels),
|
||||
}
|
||||
@@ -0,0 +1,460 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import fetch from 'node-fetch'
|
||||
import { AbortController } from 'abort-controller'
|
||||
import { expressify } from '@overleaf/promise-utils'
|
||||
import SessionManager from '../../../../app/src/Features/Authentication/SessionManager.mjs'
|
||||
import { User } from '../../../../app/src/models/User.mjs'
|
||||
import Settings from '@overleaf/settings'
|
||||
import { getSystemPrompt, getAdminLLMSettings } from './LLMAdminController.mjs'
|
||||
|
||||
// Helper function to remove <think> tags (for DeepSeek, Qwen and similar models)
|
||||
// Handles both closed <think>...</think> and unclosed <think>... at end of string
|
||||
function stripThinkTags(content) {
|
||||
let cleaned = content.replace(/<think>[\s\S]*?<\/think>\s*/gi, '')
|
||||
cleaned = cleaned.replace(/<think>[\s\S]*/gi, '')
|
||||
return cleaned.trim()
|
||||
}
|
||||
|
||||
// Parse available models from admin settings or environment variable
|
||||
async function getAvailableModels() {
|
||||
if (Settings.llm && !Settings.llm.enabled) {
|
||||
logger.debug({ llmEnabled: Settings.llm.enabled }, '[LLM] getAvailableModels: LLM disabled')
|
||||
return []
|
||||
}
|
||||
|
||||
const adminSettings = await getAdminLLMSettings()
|
||||
let modelsList = Array.isArray(adminSettings.allowedModels)
|
||||
? adminSettings.allowedModels.filter(m => typeof m === 'string' && m.trim().length > 0)
|
||||
: []
|
||||
|
||||
if (modelsList.length === 0) {
|
||||
const modelsEnv = process.env.LLM_AVAILABLE_MODELS || process.env.LLM_MODEL_NAME
|
||||
logger.debug(
|
||||
{
|
||||
LLM_AVAILABLE_MODELS: process.env.LLM_AVAILABLE_MODELS,
|
||||
LLM_MODEL_NAME: process.env.LLM_MODEL_NAME,
|
||||
resolved: modelsEnv,
|
||||
},
|
||||
'[LLM] getAvailableModels: reading env'
|
||||
)
|
||||
|
||||
if (modelsEnv) {
|
||||
modelsList = modelsEnv
|
||||
.split(',')
|
||||
.map(m => m.trim())
|
||||
.filter(m => m.length > 0)
|
||||
}
|
||||
}
|
||||
|
||||
if (modelsList.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const result = modelsList.map((id, index) => ({
|
||||
id,
|
||||
name: id.replace(/-/g, ' ').toUpperCase(),
|
||||
isDefault: index === 0,
|
||||
}))
|
||||
|
||||
logger.debug({ count: result.length, models: result.map(m => m.id) }, '[LLM] getAvailableModels: parsed models')
|
||||
return result
|
||||
}
|
||||
|
||||
async function getModels(req, res) {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
const projectId = req.params.Project_id
|
||||
|
||||
logger.debug(
|
||||
{ projectId, userId, llmSettings: Settings.llm },
|
||||
'[LLM] getModels: request received'
|
||||
)
|
||||
|
||||
try {
|
||||
if (Settings.llm && !Settings.llm.enabled) {
|
||||
logger.debug({}, '[LLM] getModels: LLM disabled, returning empty')
|
||||
return res.json({ models: [] })
|
||||
}
|
||||
|
||||
const models = []
|
||||
|
||||
// 1. Add server-wide models from admin settings/env
|
||||
const serverModels = await getAvailableModels()
|
||||
models.push(...serverModels)
|
||||
|
||||
// 2. Add user's personal LLM model if configured and activated
|
||||
if (userId && Settings.llm && Settings.llm.allowUserSettings) {
|
||||
try {
|
||||
const user = await User.findById(
|
||||
userId,
|
||||
'useOwnLLMSettings llmModelName llmApiUrl llmApiKey'
|
||||
)
|
||||
|
||||
if (
|
||||
user &&
|
||||
user.useOwnLLMSettings &&
|
||||
user.llmModelName &&
|
||||
user.llmApiUrl &&
|
||||
user.llmApiKey
|
||||
) {
|
||||
const personalModel = {
|
||||
id: `personal-${user.llmModelName}`,
|
||||
name: `${user.llmModelName} (🔒 Personal)`,
|
||||
isDefault: false,
|
||||
isPersonal: true,
|
||||
label: 'Private',
|
||||
}
|
||||
models.push(personalModel)
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
{ userId, projectId, err: error },
|
||||
'[LLM] Error fetching user LLM settings'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
{ count: models.length, modelIds: models.map(m => m.id) },
|
||||
'[LLM] getModels: returning models'
|
||||
)
|
||||
res.json({ models })
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ userId, projectId, err: error },
|
||||
'[LLM] Error fetching available models'
|
||||
)
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to fetch available models',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function chat(req, res) {
|
||||
const { messages, model } = req.body
|
||||
const projectId = req.params.Project_id
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
|
||||
logger.debug(
|
||||
{
|
||||
projectId,
|
||||
userId,
|
||||
model,
|
||||
messageCount: Array.isArray(messages) ? messages.length : 'invalid',
|
||||
isPersonalModel: model?.startsWith('personal-'),
|
||||
},
|
||||
'[LLM] chat: request received'
|
||||
)
|
||||
|
||||
if (!messages || !Array.isArray(messages)) {
|
||||
return res.status(400).json({ error: 'Invalid messages format' })
|
||||
}
|
||||
|
||||
if (Settings.llm && !Settings.llm.enabled) {
|
||||
return res.status(503).json({ error: 'LLM service is disabled' })
|
||||
}
|
||||
|
||||
const isPersonalModel = model && model.startsWith('personal-')
|
||||
|
||||
const adminLlmSettings = await getAdminLLMSettings()
|
||||
let llmApiUrl = adminLlmSettings.llmApiUrl || process.env.LLM_API_URL
|
||||
let llmApiKey = adminLlmSettings.llmApiKey || process.env.LLM_API_KEY
|
||||
|
||||
if (isPersonalModel && userId) {
|
||||
try {
|
||||
const user = await User.findById(
|
||||
userId,
|
||||
'useOwnLLMSettings llmApiUrl llmApiKey llmModelName'
|
||||
)
|
||||
if (
|
||||
user &&
|
||||
user.useOwnLLMSettings &&
|
||||
user.llmApiUrl &&
|
||||
user.llmApiKey
|
||||
) {
|
||||
llmApiUrl = user.llmApiUrl
|
||||
llmApiKey = user.llmApiKey
|
||||
} else {
|
||||
return res.status(400).json({
|
||||
error:
|
||||
'Your LLM settings are incomplete. Please configure API URL, API Key, and Model Name in your account settings.',
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
return res.status(500).json({
|
||||
error: 'Failed to retrieve user LLM settings',
|
||||
})
|
||||
}
|
||||
} else if (!isPersonalModel) {
|
||||
if (!llmApiUrl || !llmApiKey) {
|
||||
return res.status(503).json({
|
||||
error:
|
||||
'LLM service is not configured. Please contact your administrator or configure your own LLM settings.',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!llmApiUrl || !llmApiKey) {
|
||||
return res.status(503).json({ error: 'LLM service is not configured' })
|
||||
}
|
||||
|
||||
const modelNameForApi = isPersonalModel && model
|
||||
? model.substring('personal-'.length)
|
||||
: model || 'qwen3-32b'
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => {
|
||||
controller.abort()
|
||||
}, 300000) // 5 minutes
|
||||
|
||||
try {
|
||||
const llmApiFullUrl = `${llmApiUrl}/chat/completions`
|
||||
|
||||
// Prepend admin-configured system prompt if set and not already present
|
||||
let finalMessages = messages
|
||||
const adminSystemPrompt = await getSystemPrompt()
|
||||
if (adminSystemPrompt) {
|
||||
const hasSystemMessage = messages.length > 0 && messages[0].role === 'system'
|
||||
if (hasSystemMessage) {
|
||||
finalMessages = [
|
||||
{ role: 'system', content: adminSystemPrompt + '\n\n' + messages[0].content },
|
||||
...messages.slice(1),
|
||||
]
|
||||
} else {
|
||||
finalMessages = [
|
||||
{ role: 'system', content: adminSystemPrompt },
|
||||
...messages,
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const requestBody = {
|
||||
model: modelNameForApi,
|
||||
messages: finalMessages,
|
||||
max_tokens: 8192,
|
||||
temperature: 0.7,
|
||||
}
|
||||
|
||||
const startTime = Date.now()
|
||||
|
||||
logger.info(
|
||||
{
|
||||
projectId,
|
||||
model: modelNameForApi,
|
||||
url: llmApiFullUrl,
|
||||
isPersonalModel,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
'[LLM] chat: sending request to LLM API'
|
||||
)
|
||||
|
||||
const response = await fetch(llmApiFullUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${llmApiKey}`,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
clearTimeout(timeout)
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
logger.info(
|
||||
{ projectId, status: response.status, duration: `${duration}ms` },
|
||||
'[LLM] chat: LLM API responded'
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
logger.error(
|
||||
{
|
||||
projectId,
|
||||
userId,
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
duration: `${duration}ms`,
|
||||
},
|
||||
'[LLM] API error response'
|
||||
)
|
||||
return res.status(response.status).json({
|
||||
error: 'LLM API error',
|
||||
details: errorText,
|
||||
status: response.status,
|
||||
})
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Strip <think> tags from the response content
|
||||
if (
|
||||
data.choices &&
|
||||
data.choices[0] &&
|
||||
data.choices[0].message &&
|
||||
data.choices[0].message.content
|
||||
) {
|
||||
const originalLength = data.choices[0].message.content.length
|
||||
data.choices[0].message.content = stripThinkTags(
|
||||
data.choices[0].message.content
|
||||
)
|
||||
const strippedLength = data.choices[0].message.content.length
|
||||
if (originalLength !== strippedLength) {
|
||||
logger.debug(
|
||||
{ originalLength, strippedLength },
|
||||
'[LLM] chat: stripped <think> tags'
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{ projectId, duration: `${Date.now() - startTime}ms`, model: modelNameForApi },
|
||||
'[LLM] chat: response sent successfully'
|
||||
)
|
||||
res.json(data)
|
||||
} catch (error) {
|
||||
clearTimeout(timeout)
|
||||
|
||||
if (error.name === 'AbortError') {
|
||||
return res.status(504).json({
|
||||
error: 'LLM service timeout',
|
||||
details: 'The LLM API did not respond within 5 minutes',
|
||||
})
|
||||
}
|
||||
|
||||
logger.error(
|
||||
{ projectId, userId, err: error },
|
||||
'[LLM] Error communicating with LLM service'
|
||||
)
|
||||
|
||||
res.status(500).json({
|
||||
error: 'Failed to communicate with LLM service',
|
||||
details: error.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function completion(req, res) {
|
||||
const projectId = req.params.Project_id
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
const { cursorOffset, leftContext, rightContext, language, maxLength } =
|
||||
req.body
|
||||
|
||||
logger.debug(
|
||||
{
|
||||
projectId,
|
||||
userId,
|
||||
language,
|
||||
maxLength,
|
||||
leftContextLen: leftContext?.length,
|
||||
rightContextLen: rightContext?.length,
|
||||
},
|
||||
'[LLM] completion: request received'
|
||||
)
|
||||
|
||||
if (!leftContext && !rightContext) {
|
||||
return res.status(400).json({ success: false, error: 'No context provided' })
|
||||
}
|
||||
|
||||
if (Settings.llm && !Settings.llm.enabled) {
|
||||
return res.status(503).json({ success: false, error: 'LLM service is disabled' })
|
||||
}
|
||||
|
||||
const adminLlmSettings = await getAdminLLMSettings()
|
||||
let llmApiUrl = adminLlmSettings.llmApiUrl || process.env.LLM_API_URL
|
||||
let llmApiKey = adminLlmSettings.llmApiKey || process.env.LLM_API_KEY
|
||||
|
||||
// Try user settings if server-wide not configured
|
||||
if ((!llmApiUrl || !llmApiKey) && userId) {
|
||||
try {
|
||||
const user = await User.findById(
|
||||
userId,
|
||||
'useOwnLLMSettings llmApiUrl llmApiKey llmModelName'
|
||||
)
|
||||
if (user && user.useOwnLLMSettings && user.llmApiUrl && user.llmApiKey) {
|
||||
llmApiUrl = user.llmApiUrl
|
||||
llmApiKey = user.llmApiKey
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn({ userId, err: error }, '[LLM] Error loading user settings for completion')
|
||||
}
|
||||
}
|
||||
|
||||
if (!llmApiUrl || !llmApiKey) {
|
||||
return res.status(503).json({ success: false, error: 'LLM service is not configured' })
|
||||
}
|
||||
|
||||
const completionModel =
|
||||
process.env.LLM_COMPLETION_MODEL ||
|
||||
(process.env.LLM_MODEL_NAME || 'qwen3-32b').split(',')[0].trim()
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => {
|
||||
controller.abort()
|
||||
}, 30000) // 30 seconds for completions
|
||||
|
||||
try {
|
||||
const systemPrompt = `/no_think\nYou are a text completion engine. Output ONLY the missing text. No thinking, no explanation, no markdown, no code fences, no tags. Just the raw continuation characters.`
|
||||
|
||||
const userPrompt = `Complete the text at [CURSOR]. Output only the few words that replace [CURSOR]:
|
||||
|
||||
${leftContext}[CURSOR]${rightContext}`
|
||||
|
||||
const response = await fetch(`${llmApiUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${llmApiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: completionModel,
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userPrompt },
|
||||
],
|
||||
max_tokens: maxLength || 60,
|
||||
temperature: 0.1,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
clearTimeout(timeout)
|
||||
|
||||
if (!response.ok) {
|
||||
return res
|
||||
.status(response.status)
|
||||
.json({ success: false, error: 'Completion request failed' })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const completionText =
|
||||
data.choices?.[0]?.message?.content?.trim() || ''
|
||||
|
||||
// Strip any think tags and clean up
|
||||
const cleaned = stripThinkTags(completionText)
|
||||
|
||||
res.json({ success: true, data: cleaned })
|
||||
} catch (error) {
|
||||
clearTimeout(timeout)
|
||||
|
||||
if (error.name === 'AbortError') {
|
||||
return res
|
||||
.status(504)
|
||||
.json({ success: false, error: 'Completion timeout' })
|
||||
}
|
||||
|
||||
logger.error(
|
||||
{ projectId, userId, err: error },
|
||||
'[LLM] Completion error'
|
||||
)
|
||||
res
|
||||
.status(500)
|
||||
.json({ success: false, error: 'Completion failed' })
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
chat: expressify(chat),
|
||||
getModels: expressify(getModels),
|
||||
completion: expressify(completion),
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import AuthenticationController from '../../../../app/src/Features/Authentication/AuthenticationController.mjs'
|
||||
import AuthorizationMiddleware from '../../../../app/src/Features/Authorization/AuthorizationMiddleware.mjs'
|
||||
import LLMChatController from './LLMChatController.mjs'
|
||||
import LLMSettingsController from './LLMSettingsController.mjs'
|
||||
import LLMAdminController from './LLMAdminController.mjs'
|
||||
import Settings from '@overleaf/settings'
|
||||
|
||||
export default {
|
||||
apply(webRouter) {
|
||||
logger.info(
|
||||
{
|
||||
allowUserSettings: Settings.llm?.allowUserSettings,
|
||||
apiUrl: process.env.LLM_API_URL ? '(set)' : '(not set)',
|
||||
apiKey: process.env.LLM_API_KEY ? '(set)' : '(not set)',
|
||||
modelName: process.env.LLM_MODEL_NAME,
|
||||
},
|
||||
'[LLM] Registering routes'
|
||||
)
|
||||
|
||||
// Chat and model endpoints (project-scoped)
|
||||
webRouter.post(
|
||||
'/project/:Project_id/llm/chat',
|
||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||
LLMChatController.chat
|
||||
)
|
||||
logger.debug({}, '[LLM] Route registered: POST /project/:id/llm/chat')
|
||||
|
||||
webRouter.get(
|
||||
'/project/:Project_id/llm/models',
|
||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||
LLMChatController.getModels
|
||||
)
|
||||
logger.debug({}, '[LLM] Route registered: GET /project/:id/llm/models')
|
||||
|
||||
// Inline completion endpoint (project-scoped)
|
||||
webRouter.post(
|
||||
'/project/:Project_id/llm/completion',
|
||||
AuthorizationMiddleware.ensureUserCanReadProject,
|
||||
LLMChatController.completion
|
||||
)
|
||||
logger.debug({}, '[LLM] Route registered: POST /project/:id/llm/completion')
|
||||
|
||||
// User LLM settings (only if allowed)
|
||||
if (Settings.llm && Settings.llm.allowUserSettings) {
|
||||
webRouter.get(
|
||||
'/user/llm-settings',
|
||||
AuthenticationController.requireLogin(),
|
||||
LLMSettingsController.llmSettingsPage
|
||||
)
|
||||
logger.debug({}, '[LLM] Route registered: GET /user/llm-settings')
|
||||
} else {
|
||||
logger.debug(
|
||||
{ allowUserSettings: Settings.llm?.allowUserSettings },
|
||||
'[LLM] Skipping /user/llm-settings route (user settings disabled)'
|
||||
)
|
||||
}
|
||||
|
||||
webRouter.post(
|
||||
'/user/llm-settings/check',
|
||||
AuthenticationController.requireLogin(),
|
||||
LLMSettingsController.checkLLMConnection
|
||||
)
|
||||
logger.debug({}, '[LLM] Route registered: POST /user/llm-settings/check')
|
||||
|
||||
webRouter.post(
|
||||
'/user/llm-settings',
|
||||
AuthenticationController.requireLogin(),
|
||||
LLMSettingsController.saveLLMSettings
|
||||
)
|
||||
logger.debug({}, '[LLM] Route registered: POST /user/llm-settings')
|
||||
|
||||
logger.info({}, '[LLM] All routes registered successfully')
|
||||
|
||||
// Admin routes
|
||||
webRouter.get(
|
||||
'/admin/llm/settings',
|
||||
AuthorizationMiddleware.ensureUserIsSiteAdmin,
|
||||
LLMAdminController.adminSettingsPage
|
||||
)
|
||||
logger.debug({}, '[LLM] Route registered: GET /admin/llm/settings')
|
||||
|
||||
webRouter.get(
|
||||
'/admin/llm/settings/json',
|
||||
AuthorizationMiddleware.ensureUserIsSiteAdmin,
|
||||
LLMAdminController.getAdminSettings
|
||||
)
|
||||
logger.debug({}, '[LLM] Route registered: GET /admin/llm/settings/json')
|
||||
|
||||
webRouter.post(
|
||||
'/admin/llm/settings',
|
||||
AuthorizationMiddleware.ensureUserIsSiteAdmin,
|
||||
LLMAdminController.saveAdminSettings
|
||||
)
|
||||
logger.debug({}, '[LLM] Route registered: POST /admin/llm/settings')
|
||||
|
||||
webRouter.post(
|
||||
'/admin/llm/settings/check',
|
||||
AuthorizationMiddleware.ensureUserIsSiteAdmin,
|
||||
LLMAdminController.checkAdminLLMConnection
|
||||
)
|
||||
logger.debug({}, '[LLM] Route registered: POST /admin/llm/settings/check')
|
||||
|
||||
webRouter.get(
|
||||
'/admin/llm/models',
|
||||
AuthorizationMiddleware.ensureUserIsSiteAdmin,
|
||||
LLMAdminController.scanAdminModels
|
||||
)
|
||||
logger.debug({}, '[LLM] Route registered: GET /admin/llm/models')
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import logger from '@overleaf/logger'
|
||||
import fetch from 'node-fetch'
|
||||
import { AbortController } from 'abort-controller'
|
||||
import { fileURLToPath } from 'url'
|
||||
import SessionManager from '../../../../app/src/Features/Authentication/SessionManager.mjs'
|
||||
import { User } from '../../../../app/src/models/User.mjs'
|
||||
import { expressify } from '@overleaf/promise-utils'
|
||||
|
||||
const llmSettingsPugPath = fileURLToPath(
|
||||
new URL('../../app/views/llm-settings.pug', import.meta.url)
|
||||
)
|
||||
|
||||
async function llmSettingsPage(req, res) {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
|
||||
logger.debug({ userId, pugPath: llmSettingsPugPath }, '[LLM] llmSettingsPage: rendering')
|
||||
|
||||
let user = {}
|
||||
try {
|
||||
user = await User.findById(
|
||||
userId,
|
||||
'useOwnLLMSettings llmModelName llmApiUrl llmApiKey'
|
||||
)
|
||||
} catch (err) {
|
||||
logger.warn({ userId, err }, '[LLM] Error loading user for settings page')
|
||||
}
|
||||
|
||||
const llmSettings = {
|
||||
useOwnSettings: user?.useOwnLLMSettings || false,
|
||||
modelName: user?.llmModelName || '',
|
||||
apiUrl: user?.llmApiUrl || '',
|
||||
hasApiKey: !!(user?.llmApiKey),
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
{ userId, useOwnSettings: llmSettings.useOwnSettings, hasApiKey: llmSettings.hasApiKey },
|
||||
'[LLM] llmSettingsPage: user settings loaded'
|
||||
)
|
||||
|
||||
res.render(llmSettingsPugPath, { user: { llmSettings } })
|
||||
}
|
||||
|
||||
async function checkLLMConnection(req, res) {
|
||||
const { apiUrl, apiKey: providedApiKey, modelName } = req.body
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
|
||||
logger.debug(
|
||||
{ userId, apiUrl, modelName, hasProvidedKey: !!providedApiKey },
|
||||
'[LLM] checkLLMConnection: request received'
|
||||
)
|
||||
|
||||
// If no API key provided, fall back to stored key
|
||||
let apiKey = providedApiKey
|
||||
if (!apiKey) {
|
||||
try {
|
||||
const user = await User.findById(userId, 'llmApiKey')
|
||||
if (user && user.llmApiKey) {
|
||||
apiKey = user.llmApiKey
|
||||
logger.debug({ userId }, '[LLM] checkLLMConnection: using stored API key')
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn({ err }, '[LLM] Could not fetch stored API key')
|
||||
}
|
||||
}
|
||||
|
||||
if (!apiUrl || !apiKey || !modelName) {
|
||||
return res.status(400).json({ error: 'Missing required parameters' })
|
||||
}
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => {
|
||||
controller.abort()
|
||||
}, 30000)
|
||||
|
||||
try {
|
||||
const llmApiUrl = `${apiUrl}/chat/completions`
|
||||
|
||||
const requestBody = {
|
||||
model: modelName,
|
||||
messages: [{ role: 'user', content: 'Test connection' }],
|
||||
max_tokens: 10,
|
||||
temperature: 0.7,
|
||||
}
|
||||
|
||||
const startTime = Date.now()
|
||||
|
||||
const response = await fetch(llmApiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
clearTimeout(timeout)
|
||||
const duration = Date.now() - startTime
|
||||
|
||||
logger.debug(
|
||||
{ userId, apiUrl, modelName, status: response.status, duration: `${duration}ms` },
|
||||
'[LLM] checkLLMConnection: LLM API responded'
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'LLM connection failed',
|
||||
details: errorText,
|
||||
status: response.status,
|
||||
})
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'LLM connection successful',
|
||||
duration: `${duration}ms`,
|
||||
})
|
||||
} catch (error) {
|
||||
clearTimeout(timeout)
|
||||
|
||||
if (error.name === 'AbortError') {
|
||||
return res.status(504).json({
|
||||
success: false,
|
||||
error: 'Connection timeout',
|
||||
details: 'The LLM API did not respond within 30 seconds',
|
||||
})
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to test LLM connection',
|
||||
details: error.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async function saveLLMSettings(req, res) {
|
||||
const userId = SessionManager.getLoggedInUserId(req.session)
|
||||
const { useOwnLLMSettings, llmApiKey, llmModelName, llmApiUrl } = req.body
|
||||
|
||||
logger.debug(
|
||||
{
|
||||
userId,
|
||||
useOwnLLMSettings,
|
||||
llmModelName,
|
||||
llmApiUrl,
|
||||
hasApiKey: !!llmApiKey,
|
||||
},
|
||||
'[LLM] saveLLMSettings: request received'
|
||||
)
|
||||
|
||||
try {
|
||||
if (useOwnLLMSettings) {
|
||||
const currentUser = await User.findById(userId, 'llmApiKey')
|
||||
const hasExistingApiKey = currentUser && currentUser.llmApiKey
|
||||
|
||||
if (!llmApiUrl || !llmModelName) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error:
|
||||
'API URL and Model Name are required when enabling custom LLM settings',
|
||||
})
|
||||
}
|
||||
|
||||
if (!hasExistingApiKey && (!llmApiKey || llmApiKey.trim() === '')) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error:
|
||||
'API Key is required when enabling custom LLM settings',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const updateData = {
|
||||
useOwnLLMSettings: Boolean(useOwnLLMSettings),
|
||||
llmModelName: llmModelName || '',
|
||||
llmApiUrl: llmApiUrl || '',
|
||||
}
|
||||
|
||||
// Only update API key if a new one is provided
|
||||
if (llmApiKey && llmApiKey.trim() !== '') {
|
||||
updateData.llmApiKey = llmApiKey
|
||||
}
|
||||
|
||||
await User.updateOne({ _id: userId }, { $set: updateData })
|
||||
|
||||
logger.debug({ userId, useOwnLLMSettings }, '[LLM] saveLLMSettings: saved successfully')
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'LLM settings saved successfully',
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
{ userId, err: error },
|
||||
'[LLM] Error saving settings'
|
||||
)
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to save LLM settings',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
llmSettingsPage: expressify(llmSettingsPage),
|
||||
checkLLMConnection: expressify(checkLLMConnection),
|
||||
saveLLMSettings: expressify(saveLLMSettings),
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
extends ../../../../app/views/layout-react
|
||||
|
||||
block entrypointVar
|
||||
- entrypoint = 'modules/llm/pages/admin/llm-admin-settings'
|
||||
|
||||
block vars
|
||||
- isWebsiteRedesign = true
|
||||
|
||||
block append meta
|
||||
meta(name='ol-systemPrompt' data-type='string' content=systemPrompt)
|
||||
meta(name='ol-llmApiUrl' data-type='string' content=llmApiUrl)
|
||||
meta(name='ol-hasLlmApiKey' data-type='string' content=hasLlmApiKey ? 'true' : 'false')
|
||||
meta(name='ol-allowedModels' data-type='string' content=(allowedModels || []).join(','))
|
||||
|
||||
block content
|
||||
main#main-content.content.content-alt
|
||||
#llm-admin-settings-root
|
||||
@@ -0,0 +1,14 @@
|
||||
extends ../../../../app/views/layout-react
|
||||
|
||||
block entrypointVar
|
||||
- entrypoint = 'modules/llm/pages/user/llm-settings'
|
||||
|
||||
block vars
|
||||
- isWebsiteRedesign = true
|
||||
|
||||
block append meta
|
||||
meta(name='ol-user' data-type='json' content=user)
|
||||
|
||||
block content
|
||||
main#main-content.content.content-alt
|
||||
#settings-page-root
|
||||
@@ -0,0 +1,518 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import getMeta from '@/utils/meta'
|
||||
import { postJSON } from '@/infrastructure/fetch-json'
|
||||
import useAsync from '@/shared/hooks/use-async'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import OLFormGroup from '@/shared/components/ol/ol-form-group'
|
||||
import OLFormLabel from '@/shared/components/ol/ol-form-label'
|
||||
import OLFormText from '@/shared/components/ol/ol-form-text'
|
||||
import OLFormControl from '@/shared/components/ol/ol-form-control'
|
||||
import OLNotification from '@/shared/components/ol/ol-notification'
|
||||
import OLRow from '@/shared/components/ol/ol-row'
|
||||
import OLCol from '@/shared/components/ol/ol-col'
|
||||
import OLBadge from '@/shared/components/ol/ol-badge'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
|
||||
const DEFAULT_SYSTEM_PROMPT = `You are an expert LaTeX debugging assistant and compiler error specialist.
|
||||
|
||||
**Your Primary Role - Error Debugging:**
|
||||
- Analyze LaTeX compilation errors and warnings
|
||||
- Identify syntax mistakes, missing packages, and structural issues
|
||||
- Explain errors in beginner-friendly language
|
||||
- Provide working fixes with clear explanations
|
||||
|
||||
**When a user sends a compilation error:**
|
||||
|
||||
1. **Quick Summary** (1-2 sentences)
|
||||
- What's wrong in plain English
|
||||
|
||||
2. **The Problem**
|
||||
- Explain the error clearly
|
||||
- Point to the exact issue in their code
|
||||
|
||||
3. **The Fix**
|
||||
- Show corrected code in \`\`\`latex blocks
|
||||
- Highlight what changed
|
||||
|
||||
4. **Why This Happened**
|
||||
- Brief explanation of the root cause
|
||||
- How to prevent it in future
|
||||
|
||||
**Error Analysis Guidelines:**
|
||||
- The line marked with → is where the error occurred
|
||||
- Look at surrounding context for clues
|
||||
- Common issues: typos in commands, missing packages, unmatched braces
|
||||
- Check for: \\begin without \\end, missing $, wrong package names
|
||||
|
||||
**Also Helpful With:**
|
||||
- General LaTeX syntax and commands
|
||||
- Document structure and formatting
|
||||
- Mathematical typesetting
|
||||
- Bibliography and citations
|
||||
|
||||
**Response Style:**
|
||||
- Be concise and practical
|
||||
- Use code blocks for all LaTeX examples
|
||||
- Assume the user is learning LaTeX
|
||||
- Focus on solving the immediate problem first
|
||||
|
||||
Remember: The user is likely frustrated. Be encouraging and clear!`
|
||||
|
||||
const sectionStyle: React.CSSProperties = {
|
||||
padding: '1.5rem',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--border-color-01, #dee2e6)',
|
||||
backgroundColor: 'var(--bg-light-primary, #fff)',
|
||||
marginBottom: '1.25rem',
|
||||
}
|
||||
|
||||
const sectionHeaderStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.5rem',
|
||||
marginBottom: '0.25rem',
|
||||
fontSize: '1.1rem',
|
||||
fontWeight: 600,
|
||||
}
|
||||
|
||||
const sectionDescStyle: React.CSSProperties = {
|
||||
color: 'var(--content-secondary, #6c757d)',
|
||||
fontSize: '0.875rem',
|
||||
marginBottom: '1.25rem',
|
||||
}
|
||||
|
||||
const statusBadgeStyle = (variant: 'success' | 'error'): React.CSSProperties => ({
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.25rem',
|
||||
fontSize: '0.8125rem',
|
||||
fontWeight: 500,
|
||||
color: variant === 'success'
|
||||
? 'var(--green-60, #198754)'
|
||||
: 'var(--red-60, #dc3545)',
|
||||
})
|
||||
|
||||
const stepNumberStyle: React.CSSProperties = {
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '1.5rem',
|
||||
height: '1.5rem',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: 'var(--bg-accent-01, #0d6efd)',
|
||||
color: '#fff',
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 700,
|
||||
flexShrink: 0,
|
||||
}
|
||||
|
||||
export default function LLMAdminSettingsPage() {
|
||||
const { t } = useTranslation()
|
||||
const hasStoredKey = getMeta('ol-hasLlmApiKey') === 'true'
|
||||
|
||||
const [systemPrompt, setSystemPrompt] = useState<string>(
|
||||
(getMeta('ol-systemPrompt') as string) || DEFAULT_SYSTEM_PROMPT
|
||||
)
|
||||
const [llmApiUrl, setLlmApiUrl] = useState<string>(
|
||||
(getMeta('ol-llmApiUrl') as string) || ''
|
||||
)
|
||||
const [llmApiKey, setLlmApiKey] = useState<string>('')
|
||||
const [allowedModels, setAllowedModels] = useState<string[]>(
|
||||
((getMeta('ol-allowedModels') as string) || '')
|
||||
.split(',')
|
||||
.map(m => m.trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
const [availableModels, setAvailableModels] = useState<string[]>([])
|
||||
const [scanStatus, setScanStatus] = useState<string | null>(null)
|
||||
const [testStatus, setTestStatus] = useState<string | null>(null)
|
||||
|
||||
const {
|
||||
isLoading: isSaving,
|
||||
isSuccess,
|
||||
isError,
|
||||
error,
|
||||
runAsync,
|
||||
} = useAsync()
|
||||
|
||||
const [showSuccess, setShowSuccess] = useState(false)
|
||||
useEffect(() => {
|
||||
if (isSuccess) {
|
||||
setShowSuccess(true)
|
||||
const timer = setTimeout(() => setShowSuccess(false), 4000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isSuccess])
|
||||
|
||||
const canConnect = !!llmApiUrl && (!!llmApiKey || hasStoredKey)
|
||||
|
||||
const handleSave = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
runAsync(
|
||||
postJSON('/admin/llm/settings', {
|
||||
body: {
|
||||
systemPrompt,
|
||||
llmApiUrl,
|
||||
llmApiKey,
|
||||
allowedModels,
|
||||
},
|
||||
})
|
||||
).catch(() => { })
|
||||
}
|
||||
|
||||
const testConnection = async () => {
|
||||
setTestStatus('testing')
|
||||
try {
|
||||
const resp = await postJSON('/admin/llm/settings/check', {
|
||||
body: { apiUrl: llmApiUrl, apiKey: llmApiKey },
|
||||
})
|
||||
if (resp.success) {
|
||||
setTestStatus('success')
|
||||
} else {
|
||||
setTestStatus('error')
|
||||
}
|
||||
} catch (e) {
|
||||
setTestStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
const scanModels = async () => {
|
||||
setScanStatus('scanning')
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (llmApiUrl) params.set('apiUrl', llmApiUrl)
|
||||
if (llmApiKey) params.set('apiKey', llmApiKey)
|
||||
const resp = await fetch(`/admin/llm/models?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
const json = await resp.json()
|
||||
if (json.success && Array.isArray(json.models)) {
|
||||
setAvailableModels(json.models)
|
||||
setScanStatus('success')
|
||||
setAllowedModels(prev => {
|
||||
const combined = new Set([...prev, ...json.models])
|
||||
return Array.from(combined)
|
||||
})
|
||||
} else {
|
||||
setScanStatus('error')
|
||||
}
|
||||
} catch {
|
||||
setScanStatus('error')
|
||||
}
|
||||
}
|
||||
|
||||
const toggleAllowedModel = (model: string) => {
|
||||
setAllowedModels(prev =>
|
||||
prev.includes(model)
|
||||
? prev.filter(m => m !== model)
|
||||
: [...prev, model]
|
||||
)
|
||||
}
|
||||
|
||||
const allModels = Array.from(new Set([...availableModels, ...allowedModels]))
|
||||
|
||||
return (
|
||||
<div className="container" style={{ maxWidth: '800px', margin: '0 auto' }}>
|
||||
<OLRow>
|
||||
<OLCol>
|
||||
<div style={{ padding: '2rem 0' }}>
|
||||
{/* Page header */}
|
||||
<div style={{ marginBottom: '2rem' }}>
|
||||
<h1 style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginBottom: '0.5rem' }}>
|
||||
<MaterialIcon type="smart_toy" />
|
||||
{t('llm_configuration', 'LLM Configuration')}
|
||||
</h1>
|
||||
<p style={{ color: 'var(--content-secondary, #6c757d)', margin: 0 }}>
|
||||
{t(
|
||||
'llm_admin_description',
|
||||
'Configure the AI assistant for your Overleaf instance. Set up the API connection, choose available models, and customize the system prompt.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSave}>
|
||||
{/* ── Section 1: API Connection ── */}
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionHeaderStyle}>
|
||||
<span style={stepNumberStyle}>1</span>
|
||||
<MaterialIcon type="link" />
|
||||
{t('api_connection', 'API Connection')}
|
||||
{testStatus === 'success' && (
|
||||
<OLBadge bg="success" style={{ marginLeft: 'auto', fontSize: '0.75rem' }}>
|
||||
{t('connected', 'Connected')}
|
||||
</OLBadge>
|
||||
)}
|
||||
</div>
|
||||
<p style={sectionDescStyle}>
|
||||
{t(
|
||||
'api_connection_desc',
|
||||
'Enter the endpoint URL and API key for your OpenAI-compatible LLM provider.'
|
||||
)}
|
||||
</p>
|
||||
|
||||
<OLFormGroup controlId="llm-api-url">
|
||||
<OLFormLabel>
|
||||
{t('llm_api_url', 'API Endpoint URL')}
|
||||
</OLFormLabel>
|
||||
<OLFormControl
|
||||
type="url"
|
||||
value={llmApiUrl}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setLlmApiUrl(e.target.value)
|
||||
}
|
||||
placeholder="https://api.example.com/v1"
|
||||
/>
|
||||
</OLFormGroup>
|
||||
|
||||
<OLFormGroup controlId="llm-api-key" style={{ marginBottom: '1rem' }}>
|
||||
<OLFormLabel>
|
||||
{t('llm_api_key', 'API Key')}
|
||||
</OLFormLabel>
|
||||
<OLFormControl
|
||||
type="password"
|
||||
value={llmApiKey}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setLlmApiKey(e.target.value)
|
||||
}
|
||||
placeholder={
|
||||
hasStoredKey
|
||||
? t('llm_api_key_placeholder_stored', '•••••••• (stored — leave blank to keep)')
|
||||
: t('llm_api_key_placeholder', 'Paste your API key here')
|
||||
}
|
||||
/>
|
||||
{hasStoredKey && !llmApiKey && (
|
||||
<OLFormText>
|
||||
<MaterialIcon type="check_circle" className="me-1" style={{ fontSize: '0.875rem', color: 'var(--green-60, #198754)' }} />
|
||||
{t('llm_api_key_stored', 'An API key is already stored. Leave blank to keep it.')}
|
||||
</OLFormText>
|
||||
)}
|
||||
</OLFormGroup>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem' }}>
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={testConnection}
|
||||
disabled={!canConnect}
|
||||
isLoading={testStatus === 'testing'}
|
||||
>
|
||||
<MaterialIcon type="cable" className="me-1" style={{ fontSize: '1rem' }} />
|
||||
{t('test_connection', 'Test Connection')}
|
||||
</OLButton>
|
||||
{testStatus === 'success' && (
|
||||
<span style={statusBadgeStyle('success')}>
|
||||
<MaterialIcon type="check_circle" style={{ fontSize: '1rem' }} />
|
||||
{t('connection_successful', 'Connection successful')}
|
||||
</span>
|
||||
)}
|
||||
{testStatus === 'error' && (
|
||||
<span style={statusBadgeStyle('error')}>
|
||||
<MaterialIcon type="error" style={{ fontSize: '1rem' }} />
|
||||
{t('connection_failed', 'Connection failed — check URL and key')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Section 2: Model Selection ── */}
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionHeaderStyle}>
|
||||
<span style={stepNumberStyle}>2</span>
|
||||
<MaterialIcon type="model_training" />
|
||||
{t('model_selection', 'Model Selection')}
|
||||
{allModels.length > 0 && (
|
||||
<span style={{ marginLeft: 'auto', fontSize: '0.8125rem', color: 'var(--content-secondary, #6c757d)' }}>
|
||||
{allowedModels.filter(m => allModels.includes(m)).length}/{allModels.length} {t('selected', 'selected')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p style={sectionDescStyle}>
|
||||
{t(
|
||||
'model_selection_desc',
|
||||
'Scan the API for available models, then choose which ones users can access.'
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: allModels.length > 0 ? '1rem' : 0 }}>
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={scanModels}
|
||||
disabled={!canConnect}
|
||||
isLoading={scanStatus === 'scanning'}
|
||||
>
|
||||
<MaterialIcon type="radar" className="me-1" style={{ fontSize: '1rem' }} />
|
||||
{t('scan_for_models', 'Scan for Models')}
|
||||
</OLButton>
|
||||
{scanStatus === 'success' && (
|
||||
<span style={statusBadgeStyle('success')}>
|
||||
<MaterialIcon type="check_circle" style={{ fontSize: '1rem' }} />
|
||||
{t('scan_found_models', `Found ${availableModels.length} model(s)`)}
|
||||
</span>
|
||||
)}
|
||||
{scanStatus === 'error' && (
|
||||
<span style={statusBadgeStyle('error')}>
|
||||
<MaterialIcon type="error" style={{ fontSize: '1rem' }} />
|
||||
{t('scan_failed', 'Scan failed — check connection first')}
|
||||
</span>
|
||||
)}
|
||||
{!canConnect && scanStatus === null && (
|
||||
<span style={{ fontSize: '0.8125rem', color: 'var(--content-secondary, #6c757d)' }}>
|
||||
{t('configure_api_first', 'Configure the API connection above first')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{allModels.length > 0 && (
|
||||
<>
|
||||
<div style={{
|
||||
border: '1px solid var(--border-color-01, #dee2e6)',
|
||||
borderRadius: '6px',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
{allModels.map((model, idx) => (
|
||||
<label
|
||||
key={model}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '0.75rem',
|
||||
padding: '0.625rem 1rem',
|
||||
borderBottom: idx < allModels.length - 1
|
||||
? '1px solid var(--border-color-01, #dee2e6)'
|
||||
: undefined,
|
||||
cursor: 'pointer',
|
||||
margin: 0,
|
||||
transition: 'background-color 0.15s',
|
||||
}}
|
||||
onMouseEnter={e => { (e.currentTarget as HTMLElement).style.backgroundColor = 'var(--bg-light-secondary, #f8f9fa)' }}
|
||||
onMouseLeave={e => { (e.currentTarget as HTMLElement).style.backgroundColor = '' }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allowedModels.includes(model)}
|
||||
onChange={() => toggleAllowedModel(model)}
|
||||
style={{ width: '1rem', height: '1rem', accentColor: 'var(--bg-accent-01, #0d6efd)' }}
|
||||
/>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: '0.875rem' }}>
|
||||
{model}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.75rem' }}>
|
||||
<OLButton
|
||||
variant="link"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => setAllowedModels([...allModels])}
|
||||
style={{ padding: 0, fontSize: '0.8125rem' }}
|
||||
>
|
||||
{t('select_all', 'Select all')}
|
||||
</OLButton>
|
||||
<span style={{ color: 'var(--content-secondary, #6c757d)' }}>|</span>
|
||||
<OLButton
|
||||
variant="link"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => setAllowedModels([])}
|
||||
style={{ padding: 0, fontSize: '0.8125rem' }}
|
||||
>
|
||||
{t('deselect_all', 'Deselect all')}
|
||||
</OLButton>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Section 3: System Prompt ── */}
|
||||
<div style={sectionStyle}>
|
||||
<div style={sectionHeaderStyle}>
|
||||
<span style={stepNumberStyle}>3</span>
|
||||
<MaterialIcon type="description" />
|
||||
{t('system_prompt', 'System Prompt')}
|
||||
</div>
|
||||
<p style={sectionDescStyle}>
|
||||
{t(
|
||||
'system_prompt_desc',
|
||||
'This prompt is prepended to every AI conversation. Use it to customize the assistant\'s behavior for your organization.'
|
||||
)}
|
||||
</p>
|
||||
|
||||
<OLFormGroup controlId="llm-system-prompt" style={{ marginBottom: '0.5rem' }}>
|
||||
<OLFormControl
|
||||
as="textarea"
|
||||
rows={12}
|
||||
value={systemPrompt}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) =>
|
||||
setSystemPrompt(e.target.value)
|
||||
}
|
||||
placeholder={t(
|
||||
'llm_system_prompt_placeholder',
|
||||
'You are a helpful LaTeX assistant...'
|
||||
)}
|
||||
maxLength={4000}
|
||||
style={{ fontFamily: 'monospace', fontSize: '0.8125rem' }}
|
||||
/>
|
||||
</OLFormGroup>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<OLFormText style={{ margin: 0 }}>
|
||||
{systemPrompt.length}/4000 {t('characters', 'characters')}
|
||||
</OLFormText>
|
||||
<OLButton
|
||||
variant="link"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => setSystemPrompt(DEFAULT_SYSTEM_PROMPT)}
|
||||
style={{ padding: 0, fontSize: '0.8125rem' }}
|
||||
>
|
||||
<MaterialIcon type="restart_alt" className="me-1" style={{ fontSize: '1rem' }} />
|
||||
{t('reset_to_default', 'Reset to default')}
|
||||
</OLButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Notifications ── */}
|
||||
{showSuccess && (
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<OLNotification
|
||||
type="success"
|
||||
content={t('llm_settings_saved', 'LLM settings saved successfully.')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{isError && (
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<OLNotification
|
||||
type="error"
|
||||
content={
|
||||
(error as any)?.message ??
|
||||
t('generic_something_went_wrong', 'Something went wrong')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Save Button ── */}
|
||||
<OLButton
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={isSaving}
|
||||
isLoading={isSaving}
|
||||
loadingLabel={t('saving') + '…'}
|
||||
style={{ minWidth: '160px' }}
|
||||
>
|
||||
<MaterialIcon type="save" className="me-1" style={{ fontSize: '1.125rem' }} />
|
||||
{t('save_settings', 'Save Settings')}
|
||||
</OLButton>
|
||||
</form>
|
||||
</div>
|
||||
</OLCol>
|
||||
</OLRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import { useLLMChat } from '../hooks/use-llm-chat'
|
||||
import { marked } from 'marked'
|
||||
import '../../stylesheets/llm-chat.scss'
|
||||
|
||||
interface LLMChatPaneProps {
|
||||
isOpen?: boolean
|
||||
setIsOpen?: (open: boolean) => void
|
||||
}
|
||||
|
||||
const LLMChatPane = React.memo(function LLMChatPane({
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
}: LLMChatPaneProps) {
|
||||
const { t } = useTranslation()
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null)
|
||||
const {
|
||||
messages,
|
||||
isLoading,
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
rerunLastMessage,
|
||||
clearMessages,
|
||||
models,
|
||||
selectedModel,
|
||||
setSelectedModel,
|
||||
canRerun,
|
||||
modelsLoaded,
|
||||
hasModels,
|
||||
} = useLLMChat()
|
||||
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
|
||||
// Close chat panel if models become unavailable (only when controlled externally)
|
||||
useEffect(() => {
|
||||
if (modelsLoaded && !hasModels && isOpen === true && setIsOpen) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}, [modelsLoaded, hasModels, isOpen, setIsOpen])
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (inputValue.trim() && !isLoading) {
|
||||
sendMessage(inputValue)
|
||||
setInputValue('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleStop = () => {
|
||||
stopGeneration()
|
||||
}
|
||||
|
||||
const handleRerun = () => {
|
||||
rerunLastMessage()
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
if (confirm(t('clear_conversation_confirm', 'Clear conversation?'))) {
|
||||
clearMessages()
|
||||
}
|
||||
}
|
||||
|
||||
if (modelsLoaded && !hasModels) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (isOpen === false) {
|
||||
return null
|
||||
}
|
||||
|
||||
const displayMessages = messages.filter(m => m.role !== 'system')
|
||||
const showModelSelector = models.length > 0
|
||||
|
||||
return (
|
||||
<aside className="chat" aria-label={t('ai_assistant', 'AI Assistant')}>
|
||||
<div className="llm-chat-container">
|
||||
{/* Header with Model Selector and Action Buttons */}
|
||||
<div className="llm-chat-header">
|
||||
{showModelSelector && (
|
||||
<div className="llm-model-selector">
|
||||
<label htmlFor="model-select">
|
||||
{t('model_label', 'Model')}
|
||||
</label>
|
||||
<select
|
||||
id="model-select"
|
||||
value={selectedModel}
|
||||
onChange={e => setSelectedModel(e.target.value)}
|
||||
disabled={isLoading}
|
||||
aria-label={t('model_label', 'Model')}
|
||||
>
|
||||
{models.map(model => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{model.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="llm-action-buttons">
|
||||
{canRerun && !isLoading && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRerun}
|
||||
className="llm-action-button"
|
||||
title={t('re_run_last_question', 'Re-run last question')}
|
||||
aria-label={t('re_run_last_question', 'Re-run last question')}
|
||||
>
|
||||
<MaterialIcon type="refresh" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{displayMessages.length > 0 && !isLoading && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="llm-action-button"
|
||||
title={t('clear_conversation', 'Clear conversation')}
|
||||
aria-label={t('clear_conversation', 'Clear conversation')}
|
||||
>
|
||||
<MaterialIcon type="delete" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{displayMessages.length === 0 ? (
|
||||
<div className="llm-chat-welcome">
|
||||
<MaterialIcon type="smart_toy" className="llm-welcome-icon" />
|
||||
<h3>{t('latex_ai_assistant', 'LaTeX AI Assistant')}</h3>
|
||||
<p>
|
||||
{t(
|
||||
'ai_assistant_description',
|
||||
'Ask questions about LaTeX, get help with errors, and more.'
|
||||
)}
|
||||
</p>
|
||||
<div className="llm-suggestions">
|
||||
<p className="llm-suggestions-title">
|
||||
{t('try_asking', 'Try asking')}:
|
||||
</p>
|
||||
<ul>
|
||||
<li>"How do I create a table?"</li>
|
||||
<li>"Help me fix this equation"</li>
|
||||
<li>"How to add bibliography?"</li>
|
||||
<li>"Explain this LaTeX command"</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="llm-chat-messages">
|
||||
{displayMessages.map((msg, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`llm-message llm-message-${msg.role}`}
|
||||
>
|
||||
<div className="message-container">
|
||||
<div
|
||||
className="message-content"
|
||||
// Content comes only from the configured LLM API — not from other users
|
||||
// eslint-disable-next-line react/no-danger
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: marked.parse(msg.content) as string,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isLoading && (
|
||||
<div className="llm-message llm-message-assistant">
|
||||
<div className="message-container">
|
||||
<div className="llm-message-loading">
|
||||
<MaterialIcon
|
||||
type="smart_toy"
|
||||
className="loading-icon"
|
||||
/>
|
||||
<span>{t('thinking', 'Thinking...')}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStop}
|
||||
className="llm-stop-button"
|
||||
title={t('stop_generation', 'Stop')}
|
||||
aria-label={t('stop_generation', 'Stop')}
|
||||
>
|
||||
<MaterialIcon type="stop" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI assistant input */}
|
||||
<form className="llm-chat-input-form" onSubmit={handleSubmit}>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
id="llm-chat-input"
|
||||
className="llm-chat-textarea"
|
||||
value={inputValue}
|
||||
onChange={e => setInputValue(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSubmit(e as any)
|
||||
}
|
||||
}}
|
||||
placeholder={t(
|
||||
'ask_about_latex_placeholder',
|
||||
'Ask about LaTeX...'
|
||||
)}
|
||||
disabled={isLoading}
|
||||
rows={1}
|
||||
aria-label={t('ai_assistant_input', 'AI assistant input')}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="llm-chat-send-button"
|
||||
disabled={isLoading || !inputValue.trim()}
|
||||
aria-label={t('send', 'Send')}
|
||||
>
|
||||
<MaterialIcon type="send" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
})
|
||||
|
||||
export default LLMChatPane
|
||||
@@ -0,0 +1,49 @@
|
||||
import React, { useState } from 'react'
|
||||
import classNames from 'classnames'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import MaterialIcon from '@/shared/components/material-icon'
|
||||
import { useLLMChat } from '../hooks/use-llm-chat'
|
||||
import LLMChatPane from './llm-chat-pane'
|
||||
|
||||
function LLMChatToggleButton() {
|
||||
const { t } = useTranslation()
|
||||
const { modelsLoaded, modelsError, hasModels } = useLLMChat()
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
|
||||
// Hide button only when models have been successfully loaded but none are available
|
||||
// (explicit server-side "LLM not configured"). Keep visible on errors.
|
||||
if (modelsLoaded && !modelsError && !hasModels) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={classNames('ol-cm-toolbar-button', { active: isOpen })}
|
||||
onClick={() => setIsOpen(prev => !prev)}
|
||||
title={t('ai_assistant', 'AI Assistant')}
|
||||
aria-label={t('ai_assistant', 'AI Assistant')}
|
||||
>
|
||||
<MaterialIcon type="smart_toy" />
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: 420,
|
||||
zIndex: 100,
|
||||
boxShadow: '-2px 0 12px rgba(0,0,0,0.15)',
|
||||
}}
|
||||
>
|
||||
<LLMChatPane isOpen={isOpen} onClose={() => setIsOpen(false)} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default LLMChatToggleButton
|
||||
@@ -0,0 +1,666 @@
|
||||
// LLM Inline Completion for CodeMirror
|
||||
// Adapted from lcpu-club/overleaf — provides ghost text inline suggestions
|
||||
// Uses the module's /project/:id/llm/completion endpoint
|
||||
|
||||
import {
|
||||
Extension,
|
||||
StateEffect,
|
||||
StateField,
|
||||
EditorState,
|
||||
Prec,
|
||||
} from '@codemirror/state'
|
||||
import {
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
ViewUpdate,
|
||||
Decoration,
|
||||
DecorationSet,
|
||||
WidgetType,
|
||||
keymap,
|
||||
} from '@codemirror/view'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
// =================================================================================
|
||||
// Types
|
||||
// =================================================================================
|
||||
|
||||
export interface CompletionOptions {
|
||||
cursorOffset: number
|
||||
leftContext: string
|
||||
rightContext: string
|
||||
language: string
|
||||
maxLength: number
|
||||
fileList: string[]
|
||||
outline: string[]
|
||||
}
|
||||
|
||||
type Suggestion = {
|
||||
from: number
|
||||
text: string
|
||||
preview: string
|
||||
}
|
||||
|
||||
type LlmCompletionResult =
|
||||
| { kind: 'ok'; data: string }
|
||||
| { kind: 'aborted' }
|
||||
| { kind: 'error'; reason?: string; body?: any }
|
||||
|
||||
// =================================================================================
|
||||
// Backend Service
|
||||
// =================================================================================
|
||||
|
||||
class LlmCompletionService {
|
||||
async createCompletion(
|
||||
options: CompletionOptions,
|
||||
signal?: AbortSignal
|
||||
): Promise<LlmCompletionResult> {
|
||||
try {
|
||||
const projectId = getMeta('ol-project_id')
|
||||
const csrfToken = getMeta('ol-csrfToken')
|
||||
|
||||
const res = await fetch(`/project/${projectId}/llm/completion`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrfToken,
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(options),
|
||||
signal,
|
||||
})
|
||||
|
||||
let parsed: any
|
||||
try {
|
||||
parsed = await res.json()
|
||||
} catch {
|
||||
return { kind: 'error', reason: 'invalid-json' }
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
return { kind: 'error', reason: 'http-error', body: parsed }
|
||||
}
|
||||
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === 'object' &&
|
||||
typeof parsed.success === 'boolean'
|
||||
) {
|
||||
if (parsed.success && typeof parsed.data === 'string') {
|
||||
return { kind: 'ok', data: parsed.data }
|
||||
} else {
|
||||
return { kind: 'error', reason: 'backend-failure', body: parsed }
|
||||
}
|
||||
}
|
||||
|
||||
return { kind: 'error', reason: 'unexpected-format', body: parsed }
|
||||
} catch (err: any) {
|
||||
if (err.name === 'AbortError') {
|
||||
return { kind: 'aborted' }
|
||||
}
|
||||
return { kind: 'error', reason: 'network-or-unknown' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const llmCompletion = new LlmCompletionService()
|
||||
|
||||
// =================================================================================
|
||||
// State Management
|
||||
// =================================================================================
|
||||
|
||||
const setSuggestionEffect = StateEffect.define<Suggestion | null>()
|
||||
|
||||
const suggestionField = StateField.define<Suggestion | null>({
|
||||
create: () => null,
|
||||
update(value, tr) {
|
||||
for (const e of tr.effects) {
|
||||
if (e.is(setSuggestionEffect)) return e.value
|
||||
}
|
||||
|
||||
if (!value) return value
|
||||
if (tr.docChanged) {
|
||||
let changeCount = 0
|
||||
let isSimpleInsertion = true
|
||||
let insertedText = ''
|
||||
let changeFrom = -1
|
||||
|
||||
tr.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => {
|
||||
changeCount++
|
||||
if (fromA !== toA || inserted.toString().includes('\n')) {
|
||||
isSimpleInsertion = false
|
||||
}
|
||||
insertedText += inserted.toString()
|
||||
if (changeFrom === -1) changeFrom = fromA
|
||||
})
|
||||
|
||||
const shouldUpdate =
|
||||
isSimpleInsertion &&
|
||||
changeCount === 1 &&
|
||||
changeFrom === value.from &&
|
||||
insertedText.length > 0 &&
|
||||
value.text.startsWith(insertedText)
|
||||
|
||||
if (shouldUpdate) {
|
||||
const newText = value.text.slice(insertedText.length)
|
||||
if (newText.length > 0) {
|
||||
const newFrom = value.from + insertedText.length
|
||||
return {
|
||||
from: newFrom,
|
||||
text: newText,
|
||||
preview: createPreview(newText),
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (
|
||||
tr.selection &&
|
||||
(!tr.selection.main.empty || tr.selection.main.from !== value.from)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
return value
|
||||
},
|
||||
provide: f => [
|
||||
EditorView.decorations.compute([f], state => {
|
||||
const suggestion = state.field(f)
|
||||
return suggestion
|
||||
? renderGhostDecorations(suggestion, state)
|
||||
: Decoration.none
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
// =================================================================================
|
||||
// UI Widgets & Decorations
|
||||
// =================================================================================
|
||||
|
||||
class GhostTextWidget extends WidgetType {
|
||||
private static readonly MAX_PREVIEW_LINES = 3
|
||||
|
||||
constructor(readonly text: string) {
|
||||
super()
|
||||
}
|
||||
|
||||
eq(other: GhostTextWidget) {
|
||||
return this.text === other.text
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const span = document.createElement('span')
|
||||
span.className = 'cm-ghostText'
|
||||
|
||||
let text = this.text
|
||||
const lines = text.split('\n')
|
||||
if (lines.length > GhostTextWidget.MAX_PREVIEW_LINES) {
|
||||
text =
|
||||
lines.slice(0, GhostTextWidget.MAX_PREVIEW_LINES).join('\n') + '\n...'
|
||||
}
|
||||
|
||||
span.textContent = text
|
||||
return span
|
||||
}
|
||||
}
|
||||
|
||||
function renderGhostDecorations(
|
||||
s: Suggestion,
|
||||
_state: EditorState
|
||||
): DecorationSet {
|
||||
return Decoration.set([
|
||||
Decoration.widget({
|
||||
widget: new GhostTextWidget(s.text),
|
||||
side: 1,
|
||||
}).range(s.from),
|
||||
])
|
||||
}
|
||||
|
||||
// =================================================================================
|
||||
// Core Plugin Logic
|
||||
// =================================================================================
|
||||
|
||||
class InlineCompletionPlugin {
|
||||
private view: EditorView
|
||||
|
||||
private config = {
|
||||
language: 'latex',
|
||||
debounceMs: 1000,
|
||||
maxLeftLines: 10,
|
||||
maxRightLines: 2,
|
||||
maxLength: 60,
|
||||
}
|
||||
|
||||
private debounceTimer: number | null = null
|
||||
private isActive = false
|
||||
private currentSuggestionPos = 0
|
||||
private requestAbortController: AbortController | null = null
|
||||
|
||||
private requestSeq = 0
|
||||
private latestRequestId = 0
|
||||
private pendingRequestSeed: string | null = null
|
||||
private readonly seedLen = 50
|
||||
|
||||
private isComposing = false
|
||||
private readonly compositionStartHandler: () => void
|
||||
private readonly compositionEndHandler: () => void
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.view = view
|
||||
|
||||
this.compositionStartHandler = () => {
|
||||
this.isComposing = true
|
||||
}
|
||||
this.compositionEndHandler = () => {
|
||||
this.isComposing = false
|
||||
if (typeof requestIdleCallback === 'function') {
|
||||
requestIdleCallback(
|
||||
() => {
|
||||
if (!this.isComposing) {
|
||||
this.debounceTrigger()
|
||||
}
|
||||
},
|
||||
{ timeout: 500 }
|
||||
)
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
if (!this.isComposing) {
|
||||
this.debounceTrigger()
|
||||
}
|
||||
}, 200)
|
||||
}
|
||||
}
|
||||
this.setupCompositionListeners()
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (this.isComposing || !update.state.selection.main.empty) {
|
||||
this.cancel('composing or selection not empty')
|
||||
return
|
||||
}
|
||||
|
||||
if (update.selectionSet && this.isActive) {
|
||||
const suggestion = this.view.state.field(suggestionField, false)
|
||||
if (
|
||||
suggestion &&
|
||||
update.state.selection.main.from !== suggestion.from
|
||||
) {
|
||||
this.cancel('cursor moved')
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldTriggerOnInsertion(update, 30)) {
|
||||
this.debounceTrigger()
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.teardownCompositionListeners()
|
||||
this.cancel('plugin destroyed')
|
||||
}
|
||||
|
||||
private setupCompositionListeners() {
|
||||
this.view.dom.addEventListener(
|
||||
'compositionstart',
|
||||
this.compositionStartHandler
|
||||
)
|
||||
this.view.dom.addEventListener(
|
||||
'compositionend',
|
||||
this.compositionEndHandler
|
||||
)
|
||||
}
|
||||
|
||||
private teardownCompositionListeners() {
|
||||
this.view.dom.removeEventListener(
|
||||
'compositionstart',
|
||||
this.compositionStartHandler
|
||||
)
|
||||
this.view.dom.removeEventListener(
|
||||
'compositionend',
|
||||
this.compositionEndHandler
|
||||
)
|
||||
}
|
||||
|
||||
private debounceTrigger() {
|
||||
if (this.debounceTimer) clearTimeout(this.debounceTimer)
|
||||
this.debounceTimer = window.setTimeout(() => {
|
||||
this.trigger().catch(err => {
|
||||
console.error(
|
||||
'[LLM-Completion] Unhandled error in trigger:',
|
||||
err
|
||||
)
|
||||
})
|
||||
}, this.config.debounceMs)
|
||||
}
|
||||
|
||||
triggerManual() {
|
||||
this.trigger().catch(err => {
|
||||
console.error(
|
||||
'[LLM-Completion] Unhandled error in manual trigger:',
|
||||
err
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private async trigger() {
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer)
|
||||
this.debounceTimer = null
|
||||
}
|
||||
this.cancel('new request')
|
||||
|
||||
if (this.isComposing) return
|
||||
|
||||
const pos = this.view.state.selection.main.head
|
||||
const { leftContext, rightContext } = computeContexts(
|
||||
this.view,
|
||||
pos,
|
||||
this.config.maxLeftLines,
|
||||
this.config.maxRightLines
|
||||
)
|
||||
|
||||
this.isActive = true
|
||||
this.currentSuggestionPos = pos
|
||||
this.requestAbortController = new AbortController()
|
||||
|
||||
const seedLines = 3
|
||||
const { leftContext: seedLeft, rightContext: seedRight } =
|
||||
computeContexts(this.view, pos, seedLines, seedLines)
|
||||
const seed =
|
||||
(seedLeft.slice(-this.seedLen) || '') +
|
||||
'|' +
|
||||
(seedRight.slice(0, this.seedLen) || '')
|
||||
this.pendingRequestSeed = seed
|
||||
const requestId = ++this.requestSeq
|
||||
this.latestRequestId = requestId
|
||||
|
||||
const { filelist, outline } = this.collectProjectContext()
|
||||
const language = this.getCurrentFileLanguage()
|
||||
|
||||
const result = await llmCompletion.createCompletion(
|
||||
{
|
||||
cursorOffset: pos,
|
||||
leftContext,
|
||||
rightContext,
|
||||
language,
|
||||
maxLength: this.config.maxLength,
|
||||
fileList: filelist,
|
||||
outline: outline,
|
||||
},
|
||||
this.requestAbortController.signal
|
||||
)
|
||||
|
||||
if (result.kind === 'aborted' || requestId !== this.latestRequestId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (result.kind === 'error') {
|
||||
this.cancel('request error')
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
!this.verifySeedAtPosition(
|
||||
this.currentSuggestionPos,
|
||||
this.pendingRequestSeed
|
||||
)
|
||||
) {
|
||||
this.cancel('seed-mismatch')
|
||||
return
|
||||
}
|
||||
|
||||
const completionText = result.data
|
||||
if (!completionText) {
|
||||
this.cancel('no-completion')
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
this.view.state.selection.main.head !== this.currentSuggestionPos
|
||||
) {
|
||||
this.cancel('cursor-moved-after-request')
|
||||
return
|
||||
}
|
||||
|
||||
this.view.dispatch({
|
||||
effects: setSuggestionEffect.of({
|
||||
from: this.currentSuggestionPos,
|
||||
text: completionText,
|
||||
preview: createPreview(completionText),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
accept(): boolean {
|
||||
const suggestion = this.view.state.field(suggestionField, false)
|
||||
if (!suggestion) return false
|
||||
|
||||
if (this.view.state.selection.main.head !== suggestion.from) {
|
||||
this.cancel('accept-failed-cursor-moved')
|
||||
return false
|
||||
}
|
||||
|
||||
this.view.dispatch({
|
||||
changes: { from: suggestion.from, insert: suggestion.text },
|
||||
selection: { anchor: suggestion.from + suggestion.text.length },
|
||||
effects: setSuggestionEffect.of(null),
|
||||
})
|
||||
this.isActive = false
|
||||
return true
|
||||
}
|
||||
|
||||
cancel(reason?: string) {
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer)
|
||||
this.debounceTimer = null
|
||||
}
|
||||
|
||||
this.isActive = false
|
||||
this.pendingRequestSeed = null
|
||||
|
||||
if (this.requestAbortController) {
|
||||
this.requestAbortController.abort()
|
||||
this.requestAbortController = null
|
||||
}
|
||||
|
||||
if (this.view.state.field(suggestionField, false)) {
|
||||
this.view.dispatch({ effects: setSuggestionEffect.of(null) })
|
||||
}
|
||||
}
|
||||
|
||||
private collectProjectContext() {
|
||||
if (typeof document === 'undefined')
|
||||
return { filelist: [], outline: [] }
|
||||
const filelist: string[] = []
|
||||
const outline: string[] = []
|
||||
try {
|
||||
document
|
||||
.querySelectorAll('.file-tree .entity-name')
|
||||
.forEach(el => {
|
||||
const text = el.textContent?.trim()
|
||||
if (text) filelist.push(text)
|
||||
})
|
||||
document
|
||||
.querySelectorAll('.outline-pane .outline-item')
|
||||
.forEach(el => {
|
||||
const text = el.textContent?.trim()
|
||||
if (text) outline.push(text)
|
||||
})
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return { filelist, outline }
|
||||
}
|
||||
|
||||
private getCurrentFileLanguage(): string {
|
||||
if (typeof document === 'undefined') return this.config.language
|
||||
try {
|
||||
const selectedItem = document.querySelector(
|
||||
'.file-tree li.selected span'
|
||||
)
|
||||
const fileName = selectedItem?.textContent?.trim() || ''
|
||||
const parts = fileName.split('.')
|
||||
if (parts.length > 1) {
|
||||
return parts.pop()!
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return this.config.language
|
||||
}
|
||||
|
||||
private verifySeedAtPosition(
|
||||
pos: number,
|
||||
seed: string | null
|
||||
): boolean {
|
||||
if (!seed) return false
|
||||
const seedLines = 3
|
||||
const { leftContext, rightContext } = computeContexts(
|
||||
this.view,
|
||||
pos,
|
||||
seedLines,
|
||||
seedLines
|
||||
)
|
||||
const currentSeed =
|
||||
(leftContext.slice(-this.seedLen) || '') +
|
||||
'|' +
|
||||
(rightContext.slice(0, this.seedLen) || '')
|
||||
return currentSeed === seed
|
||||
}
|
||||
}
|
||||
|
||||
// =================================================================================
|
||||
// Helpers
|
||||
// =================================================================================
|
||||
|
||||
function createPreview(s: string, maxLines: number = 3): string {
|
||||
const lines = s.split('\n')
|
||||
if (lines.length <= maxLines) {
|
||||
return s
|
||||
}
|
||||
return lines.slice(0, maxLines).join('\n') + '\n...'
|
||||
}
|
||||
|
||||
function computeContexts(
|
||||
view: EditorView,
|
||||
pos: number,
|
||||
maxLeftLines: number,
|
||||
maxRightLines: number
|
||||
) {
|
||||
const doc = view.state.doc
|
||||
const currentLine = doc.lineAt(pos)
|
||||
|
||||
const leftStartLine = Math.max(1, currentLine.number - maxLeftLines)
|
||||
const leftStartPos = doc.line(leftStartLine).from
|
||||
const leftContext = doc.sliceString(leftStartPos, pos)
|
||||
|
||||
const rightEndLine = Math.min(
|
||||
doc.lines,
|
||||
currentLine.number + maxRightLines
|
||||
)
|
||||
const rightEndPos = doc.line(rightEndLine).to
|
||||
const rightContext = doc.sliceString(pos, rightEndPos)
|
||||
|
||||
return { leftContext, rightContext }
|
||||
}
|
||||
|
||||
function shouldTriggerOnInsertion(
|
||||
update: ViewUpdate,
|
||||
maxInsertThreshold: number
|
||||
): boolean {
|
||||
if (!update.docChanged) return false
|
||||
|
||||
let insertedTextLength = 0
|
||||
let isSimpleInsertion = true
|
||||
|
||||
update.changes.iterChanges((fromA, toA, _fromB, _toB, inserted) => {
|
||||
const ins = inserted.toString()
|
||||
if (fromA !== toA || ins.includes('\n')) {
|
||||
isSimpleInsertion = false
|
||||
}
|
||||
insertedTextLength += ins.length
|
||||
})
|
||||
|
||||
return (
|
||||
isSimpleInsertion &&
|
||||
insertedTextLength > 0 &&
|
||||
insertedTextLength <= maxInsertThreshold
|
||||
)
|
||||
}
|
||||
|
||||
// =================================================================================
|
||||
// Export
|
||||
// =================================================================================
|
||||
|
||||
export const INLINE_COMPLETION_PLUGIN = ViewPlugin.define(
|
||||
(view: EditorView) => new InlineCompletionPlugin(view),
|
||||
{
|
||||
destroy: plugin => plugin.destroy(),
|
||||
}
|
||||
)
|
||||
|
||||
export function inlineCompletionExtension(): Extension {
|
||||
return [
|
||||
suggestionField,
|
||||
INLINE_COMPLETION_PLUGIN,
|
||||
Prec.highest(
|
||||
keymap.of([
|
||||
{
|
||||
key: 'Tab',
|
||||
run: view => {
|
||||
const plugin = view.plugin(INLINE_COMPLETION_PLUGIN)
|
||||
if (plugin) {
|
||||
const accepted = plugin.accept()
|
||||
if (accepted) return true
|
||||
}
|
||||
return false // fall through to default Tab behaviour
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'Mod-Enter',
|
||||
run: view => {
|
||||
const plugin = view.plugin(INLINE_COMPLETION_PLUGIN)
|
||||
return plugin ? plugin.accept() : false
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'Escape',
|
||||
run: view => {
|
||||
const plugin = view.plugin(INLINE_COMPLETION_PLUGIN)
|
||||
if (plugin) {
|
||||
plugin.cancel('escape key')
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'Mod-\\',
|
||||
run: view => {
|
||||
const plugin = view.plugin(INLINE_COMPLETION_PLUGIN)
|
||||
if (plugin) {
|
||||
plugin.triggerManual()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
},
|
||||
])
|
||||
),
|
||||
EditorView.baseTheme({
|
||||
'.cm-ghostText': {
|
||||
opacity: '0.45',
|
||||
color: 'var(--cm-ghost-foreground, #888)',
|
||||
pointerEvents: 'none',
|
||||
fontFamily: 'inherit',
|
||||
fontSize: 'inherit',
|
||||
lineHeight: 'inherit',
|
||||
fontWeight: 'inherit',
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordWrap: 'break-word',
|
||||
maxWidth: '100%',
|
||||
verticalAlign: 'baseline',
|
||||
},
|
||||
}),
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import RailPanelHeader from '@/features/ide-react/components/rail/rail-panel-header'
|
||||
import LLMChatPane from './llm-chat-pane'
|
||||
import { RailElement } from '@/features/ide-react/util/rail-types'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
function LLMRailPane() {
|
||||
const { t } = useTranslation()
|
||||
|
||||
return (
|
||||
<div className="llm-rail-panel">
|
||||
<RailPanelHeader title={t('ai_assistant', 'AI Assistant')} />
|
||||
<LLMChatPane />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const llmRailEntry: RailElement = {
|
||||
key: 'llm-chat',
|
||||
icon: 'smart_toy',
|
||||
title: 'AI Assistant',
|
||||
component: <LLMRailPane />,
|
||||
hide: () => !(getMeta('ol-ExposedSettings') as any)?.llmEnabled,
|
||||
}
|
||||
|
||||
export default llmRailEntry
|
||||
@@ -0,0 +1,38 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import getMeta from '@/utils/meta'
|
||||
import LLMSettingsSection from './llm-settings-section'
|
||||
import OLRow from '@/shared/components/ol/ol-row'
|
||||
import OLCol from '@/shared/components/ol/ol-col'
|
||||
import OLPageContentCard from '@/shared/components/ol/ol-page-content-card'
|
||||
import { UserProvider } from '@/shared/context/user-context'
|
||||
import useWaitForI18n from '@/shared/hooks/use-wait-for-i18n'
|
||||
import useScrollToIdOnLoad from '@/shared/hooks/use-scroll-to-id-on-load'
|
||||
|
||||
export default function LLMSettingsPage() {
|
||||
const { t } = useTranslation()
|
||||
const { isReady } = useWaitForI18n()
|
||||
useScrollToIdOnLoad()
|
||||
const user = getMeta('ol-user') || {}
|
||||
|
||||
return (
|
||||
<div className="container">
|
||||
<OLRow>
|
||||
<OLCol xl={{ span: 10, offset: 1 }}>
|
||||
{isReady ? (
|
||||
<UserProvider>
|
||||
<OLPageContentCard>
|
||||
<div className="page-header">
|
||||
<h1>{t('llm_settings', 'LLM Settings')}</h1>
|
||||
</div>
|
||||
<div>
|
||||
<LLMSettingsSection initialSettings={user.llmSettings} />
|
||||
</div>
|
||||
</OLPageContentCard>
|
||||
</UserProvider>
|
||||
) : null}
|
||||
</OLCol>
|
||||
</OLRow>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { postJSON } from '@/infrastructure/fetch-json'
|
||||
import useAsync from '@/shared/hooks/use-async'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import OLFormGroup from '@/shared/components/ol/ol-form-group'
|
||||
import OLFormLabel from '@/shared/components/ol/ol-form-label'
|
||||
import OLFormControl from '@/shared/components/ol/ol-form-control'
|
||||
import OLFormText from '@/shared/components/ol/ol-form-text'
|
||||
import OLNotification from '@/shared/components/ol/ol-notification'
|
||||
|
||||
type Props = {
|
||||
initialSettings?: {
|
||||
useOwnSettings?: boolean
|
||||
modelName?: string
|
||||
apiUrl?: string
|
||||
hasApiKey?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export default function LLMSettingsSection({ initialSettings }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const [useOwnLLMSettings, setUseOwnLLMSettings] = useState(
|
||||
initialSettings?.useOwnSettings || false
|
||||
)
|
||||
const [llmApiKey, setLlmApiKey] = useState('')
|
||||
const [llmModelName, setLlmModelName] = useState(
|
||||
initialSettings?.modelName || ''
|
||||
)
|
||||
const [llmApiUrl, setLlmApiUrl] = useState(initialSettings?.apiUrl || '')
|
||||
const [llmHasApiKey, setLlmHasApiKey] = useState(
|
||||
initialSettings?.hasApiKey || false
|
||||
)
|
||||
const [isCheckingConnection, setIsCheckingConnection] = useState(false)
|
||||
const [connectionCheckResult, setConnectionCheckResult] = useState<{
|
||||
success: boolean
|
||||
message: string
|
||||
} | null>(null)
|
||||
const {
|
||||
isLoading: isLlmSaving,
|
||||
isSuccess: isLlmSuccess,
|
||||
isError: isLlmError,
|
||||
error: llmError,
|
||||
runAsync: runLlmAsync,
|
||||
} = useAsync()
|
||||
|
||||
const [showSuccessNotif, setShowSuccessNotif] = useState(false)
|
||||
useEffect(() => {
|
||||
if (isLlmSuccess) {
|
||||
setShowSuccessNotif(true)
|
||||
const timer = setTimeout(() => setShowSuccessNotif(false), 4000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isLlmSuccess])
|
||||
|
||||
const handleCheckLLMConnection = async () => {
|
||||
setIsCheckingConnection(true)
|
||||
setConnectionCheckResult(null)
|
||||
try {
|
||||
const response = await postJSON('/user/llm-settings/check', {
|
||||
body: {
|
||||
apiUrl: llmApiUrl,
|
||||
apiKey: llmApiKey || undefined,
|
||||
modelName: llmModelName,
|
||||
},
|
||||
})
|
||||
setConnectionCheckResult({
|
||||
success: true,
|
||||
message: response.message || 'Connection successful',
|
||||
})
|
||||
} catch (err: any) {
|
||||
setConnectionCheckResult({
|
||||
success: false,
|
||||
message: err.message || 'Connection failed',
|
||||
})
|
||||
} finally {
|
||||
setIsCheckingConnection(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveLLMSettings = () => {
|
||||
runLlmAsync(
|
||||
postJSON('/user/llm-settings', {
|
||||
body: {
|
||||
useOwnLLMSettings,
|
||||
llmApiKey: llmApiKey || undefined,
|
||||
llmModelName,
|
||||
llmApiUrl,
|
||||
},
|
||||
})
|
||||
)
|
||||
.then(() => {
|
||||
if (llmApiKey && llmApiKey.trim() !== '') {
|
||||
setLlmHasApiKey(true)
|
||||
setLlmApiKey('')
|
||||
}
|
||||
})
|
||||
.catch(() => { })
|
||||
}
|
||||
|
||||
const handleToggleUseOwnLLMSettings = (checked: boolean) => {
|
||||
setUseOwnLLMSettings(checked)
|
||||
|
||||
if (!checked) {
|
||||
setLlmApiKey('')
|
||||
setLlmModelName('')
|
||||
setLlmApiUrl('')
|
||||
setConnectionCheckResult(null)
|
||||
|
||||
runLlmAsync(
|
||||
postJSON('/user/llm-settings', {
|
||||
body: {
|
||||
useOwnLLMSettings: false,
|
||||
llmApiKey: undefined,
|
||||
llmModelName: '',
|
||||
llmApiUrl: '',
|
||||
},
|
||||
})
|
||||
).catch(() => { })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<OLFormGroup>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
id="use-own-llm-settings"
|
||||
checked={useOwnLLMSettings}
|
||||
onChange={e => handleToggleUseOwnLLMSettings(e.target.checked)}
|
||||
style={{ marginRight: '0.5rem' }}
|
||||
/>
|
||||
<OLFormLabel htmlFor="use-own-llm-settings">
|
||||
{t('use_my_own_llm_settings', 'Use my own LLM settings')}
|
||||
</OLFormLabel>
|
||||
</div>
|
||||
</OLFormGroup>
|
||||
|
||||
{useOwnLLMSettings && (
|
||||
<form
|
||||
onSubmit={e => {
|
||||
e.preventDefault()
|
||||
handleSaveLLMSettings()
|
||||
}}
|
||||
>
|
||||
<OLFormGroup controlId="llm-api-key-input">
|
||||
<OLFormLabel>API Key</OLFormLabel>
|
||||
<OLFormControl
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={llmApiKey}
|
||||
onChange={e => setLlmApiKey(e.target.value)}
|
||||
placeholder={llmHasApiKey ? '***' : 'Enter API Key'}
|
||||
/>
|
||||
{llmHasApiKey && !llmApiKey && (
|
||||
<OLFormText>
|
||||
Existing API key is set. Enter a new one to update.
|
||||
</OLFormText>
|
||||
)}
|
||||
</OLFormGroup>
|
||||
|
||||
<OLFormGroup controlId="llm-model-name-input">
|
||||
<OLFormLabel>Model Name</OLFormLabel>
|
||||
<OLFormControl
|
||||
type="text"
|
||||
value={llmModelName}
|
||||
onChange={e => setLlmModelName(e.target.value)}
|
||||
placeholder="e.g., gpt-4, claude-3"
|
||||
/>
|
||||
</OLFormGroup>
|
||||
|
||||
<OLFormGroup controlId="llm-api-url-input">
|
||||
<OLFormLabel>API URL</OLFormLabel>
|
||||
<OLFormControl
|
||||
type="text"
|
||||
value={llmApiUrl}
|
||||
onChange={e => setLlmApiUrl(e.target.value)}
|
||||
placeholder="e.g., https://api.openai.com/v1"
|
||||
/>
|
||||
</OLFormGroup>
|
||||
|
||||
{connectionCheckResult && (
|
||||
<OLFormGroup>
|
||||
<OLNotification
|
||||
type={connectionCheckResult.success ? 'success' : 'error'}
|
||||
content={connectionCheckResult.message}
|
||||
/>
|
||||
</OLFormGroup>
|
||||
)}
|
||||
|
||||
<OLFormGroup>
|
||||
<OLButton
|
||||
variant="secondary"
|
||||
type="button"
|
||||
onClick={handleCheckLLMConnection}
|
||||
disabled={
|
||||
isCheckingConnection ||
|
||||
!llmApiUrl ||
|
||||
(!llmApiKey && !llmHasApiKey) ||
|
||||
!llmModelName
|
||||
}
|
||||
isLoading={isCheckingConnection}
|
||||
loadingLabel="Checking..."
|
||||
style={{ marginRight: '0.5rem' }}
|
||||
>
|
||||
Check Connection
|
||||
</OLButton>
|
||||
<OLButton
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={isLlmSaving || !llmApiUrl || !llmModelName}
|
||||
isLoading={isLlmSaving}
|
||||
loadingLabel={t('saving') + '…'}
|
||||
>
|
||||
Save LLM Settings
|
||||
</OLButton>
|
||||
</OLFormGroup>
|
||||
|
||||
{showSuccessNotif && (
|
||||
<OLFormGroup>
|
||||
<OLNotification
|
||||
type="success"
|
||||
content="LLM settings saved successfully"
|
||||
/>
|
||||
</OLFormGroup>
|
||||
)}
|
||||
|
||||
{isLlmError && (
|
||||
<OLFormGroup>
|
||||
<OLNotification
|
||||
type="error"
|
||||
content={
|
||||
llmError?.message ?? 'Failed to save LLM settings'
|
||||
}
|
||||
/>
|
||||
</OLFormGroup>
|
||||
)}
|
||||
</form>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Extension point wrapper for sourceEditorComponents
|
||||
// Provides root-level LLM editor features (inline completion, floating toolbar)
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { useCodeMirrorViewContext } from '@/features/source-editor/components/codemirror-context'
|
||||
import { inlineCompletionExtension } from './llm-completion'
|
||||
import LLMToolbar, { LLMToolbarHandle } from './llm-toolbar'
|
||||
|
||||
function LLMSourceEditorComponent() {
|
||||
const view = useCodeMirrorViewContext()
|
||||
const toolbarRef = useRef<LLMToolbarHandle>(null)
|
||||
const [extensionInstalled, setExtensionInstalled] = useState(false)
|
||||
|
||||
// Install inline completion extension
|
||||
useEffect(() => {
|
||||
if (!view || extensionInstalled) return
|
||||
try {
|
||||
const ext = inlineCompletionExtension()
|
||||
view.dispatch({
|
||||
effects: (window as any).__cm_llm_reconfigure
|
||||
? []
|
||||
: // @ts-ignore
|
||||
[],
|
||||
})
|
||||
setExtensionInstalled(true)
|
||||
} catch {
|
||||
// Extension may already be active or view not ready
|
||||
}
|
||||
}, [view, extensionInstalled])
|
||||
|
||||
// Listen for "Ask AI" from the review tooltip menu
|
||||
useEffect(() => {
|
||||
const handleAskAI = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail
|
||||
if (!detail?.text || !view || !toolbarRef.current) return
|
||||
toolbarRef.current.show(view)
|
||||
// Immediately open the menu panel
|
||||
toolbarRef.current.openMenu?.()
|
||||
}
|
||||
document.addEventListener('llm-ask-ai-selection', handleAskAI)
|
||||
return () => document.removeEventListener('llm-ask-ai-selection', handleAskAI)
|
||||
}, [view])
|
||||
|
||||
return <LLMToolbar ref={toolbarRef} />
|
||||
}
|
||||
|
||||
export default LLMSourceEditorComponent
|
||||
@@ -0,0 +1,903 @@
|
||||
// LLM Floating Toolbar for CodeMirror Editor
|
||||
// Adapted from lcpu-club/overleaf — provides selection-based AI actions
|
||||
// (paraphrase, style change, summarize, explain, etc.)
|
||||
|
||||
import React, {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
import { marked } from 'marked'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
export type LLMToolbarHandle = {
|
||||
show: (view: EditorView) => void
|
||||
hide: () => void
|
||||
openMenu: () => void
|
||||
getSelectedText: () => string
|
||||
}
|
||||
|
||||
type ParaphraseKind =
|
||||
| 'paraphrase'
|
||||
| 'style'
|
||||
| 'splitjoin'
|
||||
| 'summarize'
|
||||
| 'explain'
|
||||
| 'title'
|
||||
| 'abstract'
|
||||
| 'chat'
|
||||
|
||||
const kindTitleMap: Record<ParaphraseKind, string> = {
|
||||
paraphrase: 'Paraphrase',
|
||||
style: 'Change Style',
|
||||
splitjoin: 'Split / Join',
|
||||
summarize: 'Summarize',
|
||||
explain: 'Explain',
|
||||
title: 'Title Generator',
|
||||
abstract: 'Abstract Generator',
|
||||
chat: 'AI Response',
|
||||
}
|
||||
|
||||
function escapeHtml(s: string) {
|
||||
return s.replace(
|
||||
/[&<>"']/g,
|
||||
c =>
|
||||
({
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": ''',
|
||||
}[c] || c)
|
||||
)
|
||||
}
|
||||
|
||||
function base64EncodeUnicode(str: string) {
|
||||
try {
|
||||
return btoa(unescape(encodeURIComponent(str)))
|
||||
} catch {
|
||||
return btoa(str)
|
||||
}
|
||||
}
|
||||
|
||||
function base64DecodeUnicode(b64: string) {
|
||||
try {
|
||||
return decodeURIComponent(escape(atob(b64)))
|
||||
} catch {
|
||||
return atob(b64)
|
||||
}
|
||||
}
|
||||
|
||||
const Spinner = () => (
|
||||
<div
|
||||
style={{
|
||||
width: 16,
|
||||
height: 16,
|
||||
borderRadius: 8,
|
||||
border: '2px solid rgba(255,255,255,0.12)',
|
||||
borderTopColor: 'rgba(255,255,255,0.7)',
|
||||
animation: 'llm-spin 0.9s linear infinite',
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
const LLMToolbar = forwardRef<LLMToolbarHandle, {}>((_, ref) => {
|
||||
const [anchorShown, setAnchorShown] = useState(false)
|
||||
const [panelRect, setPanelRect] = useState({ top: 0, left: 0, width: 520 })
|
||||
const [anchorPos, setAnchorPos] = useState({ top: 0, left: 0 })
|
||||
const [panelMode, setPanelMode] = useState<
|
||||
'hidden' | 'menu' | 'chat' | 'paraphrase'
|
||||
>('hidden')
|
||||
const [submenu, setSubmenu] = useState<null | 'style' | 'splitjoin'>(null)
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const [selectionText, setSelectionText] = useState('')
|
||||
const [selectionRange, setSelectionRange] = useState<{
|
||||
from: number
|
||||
to: number
|
||||
} | null>(null)
|
||||
const [result, setResult] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [editMode, setEditMode] = useState(false)
|
||||
const [showDiff, setShowDiff] = useState(false)
|
||||
const [kind, setKind] = useState<ParaphraseKind>('paraphrase')
|
||||
|
||||
const viewRef = useRef<EditorView | null>(null)
|
||||
const wrapRef = useRef<HTMLDivElement | null>(null)
|
||||
const panelRef = useRef<HTMLDivElement | null>(null)
|
||||
const inputRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
const editRef = useRef<HTMLTextAreaElement | null>(null)
|
||||
|
||||
const postToAPI = async (mode: number, ask: string) => {
|
||||
const projectId = getMeta('ol-project_id')
|
||||
const csrfToken = getMeta('ol-csrfToken')
|
||||
|
||||
// Build a prompt based on the mode
|
||||
const modePrompts: Record<number, string> = {
|
||||
0: ask, // free-form chat
|
||||
1: `Paraphrase the following LaTeX text, keeping all LaTeX commands intact:\n\n${selectionText}`,
|
||||
2: `Rewrite the following LaTeX text in a scientific academic style:\n\n${selectionText}`,
|
||||
3: `Rewrite the following LaTeX text more concisely:\n\n${selectionText}`,
|
||||
4: `Rewrite the following LaTeX text in a punchy style:\n\n${selectionText}`,
|
||||
5: `Split the following LaTeX paragraph into multiple shorter paragraphs:\n\n${selectionText}`,
|
||||
6: `Join the following LaTeX paragraphs into a single cohesive paragraph:\n\n${selectionText}`,
|
||||
7: `Summarize the following LaTeX text:\n\n${selectionText}`,
|
||||
8: `Explain the following LaTeX text:\n\n${selectionText}`,
|
||||
9: `Generate an academic title based on this LaTeX content:\n\n${selectionText}`,
|
||||
10: `Generate an academic abstract based on this LaTeX content:\n\n${selectionText}`,
|
||||
}
|
||||
|
||||
const messages = [
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'You are a helpful LaTeX writing assistant. Respond with clean LaTeX code when appropriate.',
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: modePrompts[mode] || ask,
|
||||
},
|
||||
]
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/project/${projectId}/llm/chat`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrfToken,
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ messages }),
|
||||
})
|
||||
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
|
||||
const json = await resp.json()
|
||||
if (json?.choices?.[0]?.message?.content) {
|
||||
return json.choices[0].message.content
|
||||
}
|
||||
throw new Error('Unexpected response format')
|
||||
} catch (err: any) {
|
||||
console.error('[LLM Toolbar] API error', err)
|
||||
return `Error: ${err?.message || 'Request failed'}`
|
||||
}
|
||||
}
|
||||
|
||||
const startFetch = async (
|
||||
mode: number,
|
||||
k: ParaphraseKind,
|
||||
ask?: string
|
||||
) => {
|
||||
setKind(k)
|
||||
setPanelMode(k === 'chat' ? 'chat' : 'paraphrase')
|
||||
setLoading(true)
|
||||
setEditMode(false)
|
||||
setShowDiff(false)
|
||||
setResult('')
|
||||
|
||||
const resp = await postToAPI(mode, (ask ?? query).trim())
|
||||
setResult(resp)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
const renderer = useMemo(() => {
|
||||
const r = new marked.Renderer()
|
||||
r.code = (
|
||||
code: string,
|
||||
infostring: string | undefined,
|
||||
_escaped: boolean
|
||||
) => {
|
||||
const lang = (infostring || '').trim().toLowerCase()
|
||||
const isLatex =
|
||||
lang === 'latex' ||
|
||||
lang === 'tex' ||
|
||||
code.trim().startsWith('\\')
|
||||
const safeCodeHtml = escapeHtml(code)
|
||||
if (isLatex) {
|
||||
const b64 = base64EncodeUnicode(code)
|
||||
return `<div class="llm-latex-block" style="position:relative;"><button class="llm-copy-latex" data-code="${b64}" title="Copy LaTeX" style="position:absolute;right:8px;top:8px;border-radius:6px;padding:4px 6px;border:none;background:rgba(255,255,255,0.03);color:#e6eef8;cursor:pointer">📋</button><pre style="margin:0;"><code class="language-${escapeHtml(lang)}">${safeCodeHtml}</code></pre></div>`
|
||||
}
|
||||
return `<pre><code class="language-${escapeHtml(lang)}">${safeCodeHtml}</code></pre>`
|
||||
}
|
||||
return r
|
||||
}, [])
|
||||
|
||||
const renderedHtml = useMemo(() => {
|
||||
try {
|
||||
return marked.parse(result || 'No result yet.', { renderer })
|
||||
} catch {
|
||||
return escapeHtml(result || 'No result yet.')
|
||||
}
|
||||
}, [result, renderer])
|
||||
|
||||
// Copy button delegation
|
||||
useEffect(() => {
|
||||
const root = wrapRef.current
|
||||
if (!root) return
|
||||
const handler = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement
|
||||
const btn = target.closest
|
||||
? (target.closest('.llm-copy-latex') as HTMLElement | null)
|
||||
: null
|
||||
if (!btn) return
|
||||
const b64 = btn.getAttribute('data-code')
|
||||
if (!b64) return
|
||||
const code = base64DecodeUnicode(b64)
|
||||
; (async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(code)
|
||||
const prev = btn.innerText
|
||||
btn.innerText = '✓'
|
||||
setTimeout(() => {
|
||||
btn.innerText = prev
|
||||
}, 900)
|
||||
} catch {
|
||||
btn.innerText = '✕'
|
||||
setTimeout(() => {
|
||||
btn.innerText = '📋'
|
||||
}, 900)
|
||||
}
|
||||
})()
|
||||
}
|
||||
root.addEventListener('click', handler)
|
||||
return () => root.removeEventListener('click', handler)
|
||||
}, [])
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
show: (view: EditorView) => {
|
||||
viewRef.current = view
|
||||
const sel = view.state.selection.main
|
||||
if (sel.empty) return
|
||||
|
||||
const wrapRect = wrapRef.current?.getBoundingClientRect()
|
||||
const editorRect = view.dom.getBoundingClientRect()
|
||||
const coordsFrom = view.coordsAtPos(sel.from)
|
||||
const coordsTo = view.coordsAtPos(sel.to)
|
||||
const toLocal = (c: { left: number; top: number }) => ({
|
||||
x: wrapRect ? c.left - wrapRect.left : c.left,
|
||||
y: wrapRect ? c.top - wrapRect.top : c.top,
|
||||
})
|
||||
|
||||
let aTop = 0,
|
||||
aLeft = 0
|
||||
if (coordsFrom && coordsTo) {
|
||||
const top = Math.min(coordsFrom.top, coordsTo.top)
|
||||
const bottom = Math.max(coordsFrom.bottom, coordsTo.bottom)
|
||||
const right = Math.max(coordsFrom.right, coordsTo.right)
|
||||
const midY = top + (bottom - top) / 2
|
||||
aTop = toLocal({ left: 0, top: midY }).y - 14
|
||||
aLeft = toLocal({ left: right, top: 0 }).x - 14
|
||||
} else {
|
||||
aTop =
|
||||
editorRect.top +
|
||||
editorRect.height / 2 -
|
||||
(wrapRect?.top ?? 0) -
|
||||
14
|
||||
aLeft = editorRect.right - (wrapRect?.left ?? 0) - 44
|
||||
}
|
||||
|
||||
const containerW = wrapRect?.width ?? window.innerWidth
|
||||
const containerH = wrapRect?.height ?? window.innerHeight
|
||||
const clampedTop = Math.round(
|
||||
Math.max(8, Math.min(containerH - 44, aTop))
|
||||
)
|
||||
const clampedLeft = Math.round(
|
||||
Math.max(8, Math.min(containerW - 44, aLeft))
|
||||
)
|
||||
|
||||
const width = Math.max(320, Math.min(560, containerW - 24))
|
||||
const panelTop = Math.round(
|
||||
Math.max(
|
||||
12,
|
||||
Math.min(
|
||||
(wrapRect ? editorRect.top - wrapRect.top : 0) + 52,
|
||||
containerH - 260 - 12
|
||||
)
|
||||
)
|
||||
)
|
||||
const panelLeft = Math.round(
|
||||
Math.max(
|
||||
12,
|
||||
Math.min(
|
||||
containerW - width - 12,
|
||||
(wrapRect ? editorRect.left - wrapRect.left : 0) +
|
||||
editorRect.width / 2 -
|
||||
width / 2
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
setPanelRect({ top: panelTop, left: panelLeft, width })
|
||||
setAnchorPos({ top: clampedTop, left: clampedLeft })
|
||||
setSelectionText(view.state.sliceDoc(sel.from, sel.to))
|
||||
setSelectionRange({ from: sel.from, to: sel.to })
|
||||
setAnchorShown(true)
|
||||
setPanelMode('hidden')
|
||||
document.dispatchEvent(
|
||||
new CustomEvent('llm-toolbar-active', { detail: { active: true } })
|
||||
)
|
||||
},
|
||||
hide: () => {
|
||||
setAnchorShown(false)
|
||||
setPanelMode('hidden')
|
||||
setSubmenu(null)
|
||||
document.dispatchEvent(
|
||||
new CustomEvent('llm-toolbar-active', { detail: { active: false } })
|
||||
)
|
||||
},
|
||||
openMenu: () => {
|
||||
setAnchorShown(true)
|
||||
setPanelMode('menu')
|
||||
},
|
||||
getSelectedText: () => selectionText,
|
||||
}))
|
||||
|
||||
useEffect(() => {
|
||||
if (panelMode !== 'hidden') {
|
||||
requestAnimationFrame(() =>
|
||||
inputRef.current?.focus({ preventScroll: true } as any)
|
||||
)
|
||||
}
|
||||
}, [panelMode])
|
||||
|
||||
const onInputKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
startFetch(0, 'chat')
|
||||
} else if (e.key === 'Escape') {
|
||||
setPanelMode('hidden')
|
||||
setAnchorShown(false)
|
||||
}
|
||||
}
|
||||
|
||||
const replaceSelectionWith = (text: string) => {
|
||||
const view = viewRef.current
|
||||
const r = selectionRange
|
||||
if (!view || !r) return
|
||||
view.dispatch({ changes: { from: r.from, to: r.to, insert: text } })
|
||||
setPanelMode('hidden')
|
||||
setAnchorShown(false)
|
||||
setResult('')
|
||||
setQuery('')
|
||||
}
|
||||
|
||||
const insertCodeAfterSelection = (text: string) => {
|
||||
const view = viewRef.current
|
||||
const r = selectionRange
|
||||
if (!view || !r) return
|
||||
|
||||
// Extract LaTeX from markdown
|
||||
const extractLatex = (md: string) => {
|
||||
if (!md) return ''
|
||||
let m: RegExpExecArray | null
|
||||
let collected = ''
|
||||
|
||||
const fencedLangRegex = /```\s*(?:latex|tex)\n([\s\S]*?)```/gi
|
||||
while ((m = fencedLangRegex.exec(md)) !== null)
|
||||
collected += m[1].trim() + '\n'
|
||||
if (collected) return collected.trim()
|
||||
|
||||
const fencedAny = /```(?:\w+)?\n([\s\S]*?)```/g
|
||||
while ((m = fencedAny.exec(md)) !== null) {
|
||||
const code = m[1]
|
||||
if (/\\begin\{|\\[a-zA-Z]+|\\frac\{|\\end\{/.test(code))
|
||||
collected += code.trim() + '\n'
|
||||
}
|
||||
if (collected) return collected.trim()
|
||||
|
||||
return md.trim()
|
||||
}
|
||||
|
||||
const code = extractLatex(text)
|
||||
const toInsert = code ? '\n' + code + '\n' : '\n' + text + '\n'
|
||||
view.dispatch({ changes: { from: r.to, to: r.to, insert: toInsert } })
|
||||
setPanelMode('hidden')
|
||||
setAnchorShown(false)
|
||||
setQuery('')
|
||||
}
|
||||
|
||||
const copyToClipboard = async (t: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(t)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const kindToMode: Record<ParaphraseKind, number> = {
|
||||
paraphrase: 1,
|
||||
style: 2,
|
||||
splitjoin: 5,
|
||||
summarize: 7,
|
||||
explain: 8,
|
||||
title: 9,
|
||||
abstract: 10,
|
||||
chat: 0,
|
||||
}
|
||||
|
||||
const contentMaxHeight = Math.max(
|
||||
140,
|
||||
Math.round(window.innerHeight * 0.5)
|
||||
)
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapRef}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 9999,
|
||||
}}
|
||||
>
|
||||
<style>{`@keyframes llm-spin{from{transform:rotate(0)}to{transform:rotate(360deg)}}
|
||||
.llm-anchor{position:absolute;width:28px;height:28px;border-radius:999px;background:linear-gradient(180deg,#0b1220,#0f172a);color:#eef2ff;border:1px solid rgba(255,255,255,0.08);display:inline-flex;align-items:center;justify-content:center;box-shadow:0 6px 22px rgba(2,6,23,0.45);cursor:pointer;pointer-events:auto}
|
||||
.llm-panel{position:absolute;pointer-events:auto;user-select:none}
|
||||
.llm-input-card{width:100%;background:linear-gradient(180deg,#071021,#071827);border-radius:14px;padding:12px 12px 12px 56px;position:relative;box-shadow:0 14px 40px rgba(3,8,22,0.55);border:1px solid rgba(255,255,255,0.04)}
|
||||
.llm-badge{position:absolute;left:14px;top:50%;transform:translateY(-50%);width:30px;height:30px;border-radius:8px;background:rgba(255,255,255,0.03);display:flex;align-items:center;justify-content:center;color:#e6eef8}
|
||||
.llm-input{width:100%;min-height:40px;max-height:160px;padding:8px 84px 8px 10px;border-radius:10px;background:transparent;color:#e6eef8;border:1px solid rgba(255,255,255,0.06);outline:none;resize:none;line-height:20px;font-size:14px;box-sizing:border-box}
|
||||
.llm-send{position:absolute;right:44px;top:50%;transform:translateY(-50%);width:34px;height:34px;border-radius:9px;background:rgba(255,255,255,0.02);display:flex;align-items:center;justify-content:center;cursor:pointer}
|
||||
.llm-close{position:absolute;right:8px;top:50%;transform:translateY(-50%);width:34px;height:34px;border-radius:9px;background:rgba(255,255,255,0.02);display:flex;align-items:center;justify-content:center;cursor:pointer}
|
||||
.llm-menu{margin-top:0;background:linear-gradient(180deg,#071021,#071827);border-radius:12px;padding:10px 8px;color:#dfe7ee;border:1px solid rgba(255,255,255,0.04);box-shadow:0 14px 40px rgba(3,8,22,0.55)}
|
||||
.llm-item{height:40px;display:flex;align-items:center;gap:10px;padding:0 10px;border-radius:10px;cursor:pointer}
|
||||
.llm-item:hover{background:rgba(255,255,255,0.04)}
|
||||
.llm-paraphrase-card{pointer-events:auto;width:100%;background:#071223;border-radius:12px;padding:12px;box-shadow:0 14px 40px rgba(3,8,22,0.55);border:1px solid rgba(255,255,255,0.06);color:#e6eef8}
|
||||
.llm-paraphrase-footer{display:flex;justify-content:flex-end;gap:10px;margin-top:10px}
|
||||
.llm-btn{padding:8px 12px;border-radius:10px;cursor:pointer;border:1px solid rgba(255,255,255,0.06);background:linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.01));color:#e6eef8}
|
||||
.llm-btn.llm-primary{background:linear-gradient(180deg, rgba(16,185,129,0.14), rgba(16,185,129,0.08));border:1px solid rgba(16,185,129,0.22)}
|
||||
.llm-result-html{overflow-x:hidden;word-wrap:break-word;word-break:break-word;overflow-wrap:anywhere}
|
||||
.llm-result-html h1,.llm-result-html h2,.llm-result-html h3{color:#e6eef8;margin:6px 0}
|
||||
.llm-result-html p,.llm-result-html li{color:#dfe7ee;margin:4px 0}
|
||||
.llm-result-html pre{background:rgba(0,0,0,0.45);padding:8px;border-radius:6px;overflow-x:auto;color:#e6eef8;white-space:pre-wrap;word-wrap:break-word;max-width:100%}
|
||||
.llm-result-html code{word-wrap:break-word;white-space:pre-wrap}
|
||||
`}</style>
|
||||
|
||||
{/* Circular AI anchor button - visible after selection */}
|
||||
{anchorShown && panelMode === 'hidden' && !loading && (
|
||||
<button
|
||||
className="llm-anchor"
|
||||
style={{ top: anchorPos.top, left: anchorPos.left }}
|
||||
onClick={() => setPanelMode('menu')}
|
||||
title="AI"
|
||||
>
|
||||
AI
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Menu panel */}
|
||||
{anchorShown && panelMode === 'menu' && (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="llm-panel"
|
||||
style={{
|
||||
top: panelRect.top,
|
||||
left: panelRect.left,
|
||||
width: panelRect.width,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div className="llm-input-card">
|
||||
<div className="llm-badge">AI</div>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className="llm-input"
|
||||
placeholder="Ask AI for help"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={onInputKeyDown}
|
||||
rows={1}
|
||||
/>
|
||||
<button
|
||||
className="llm-send"
|
||||
onClick={() => startFetch(0, 'chat')}
|
||||
title="Send"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
className="llm-close"
|
||||
onClick={() => {
|
||||
setPanelMode('hidden')
|
||||
setAnchorShown(false)
|
||||
}}
|
||||
title="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="llm-menu"
|
||||
style={{ width: Math.min(220, panelRect.width - 36) }}
|
||||
>
|
||||
<div
|
||||
style={{ fontSize: 12, color: '#98a3af', padding: '6px 8px' }}
|
||||
>
|
||||
Context Options
|
||||
</div>
|
||||
<div
|
||||
className="llm-item"
|
||||
onClick={() => startFetch(1, 'paraphrase')}
|
||||
>
|
||||
✏️ Paraphrase
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{ position: 'relative' }}
|
||||
onMouseEnter={() => setSubmenu('style')}
|
||||
onMouseLeave={() => setSubmenu(null)}
|
||||
>
|
||||
<div className="llm-item">🎨 Change style</div>
|
||||
{submenu === 'style' && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '104%',
|
||||
top: 0,
|
||||
minWidth: 120,
|
||||
}}
|
||||
className="llm-menu"
|
||||
>
|
||||
<div
|
||||
className="llm-item"
|
||||
onClick={() => startFetch(2, 'style')}
|
||||
>
|
||||
Scientific
|
||||
</div>
|
||||
<div
|
||||
className="llm-item"
|
||||
onClick={() => startFetch(3, 'style')}
|
||||
>
|
||||
Concise
|
||||
</div>
|
||||
<div
|
||||
className="llm-item"
|
||||
onClick={() => startFetch(4, 'style')}
|
||||
>
|
||||
Punchy
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{ position: 'relative' }}
|
||||
onMouseEnter={() => setSubmenu('splitjoin')}
|
||||
onMouseLeave={() => setSubmenu(null)}
|
||||
>
|
||||
<div className="llm-item">🔀 Split/Join</div>
|
||||
{submenu === 'splitjoin' && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '104%',
|
||||
top: 0,
|
||||
minWidth: 120,
|
||||
}}
|
||||
className="llm-menu"
|
||||
>
|
||||
<div
|
||||
className="llm-item"
|
||||
onClick={() => startFetch(5, 'splitjoin')}
|
||||
>
|
||||
Split
|
||||
</div>
|
||||
<div
|
||||
className="llm-item"
|
||||
onClick={() => startFetch(6, 'splitjoin')}
|
||||
>
|
||||
Join
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="llm-item"
|
||||
onClick={() => startFetch(7, 'summarize')}
|
||||
>
|
||||
📝 Summarize
|
||||
</div>
|
||||
<div
|
||||
className="llm-item"
|
||||
onClick={() => startFetch(8, 'explain')}
|
||||
>
|
||||
ℹ️ Explain
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
height: 1,
|
||||
background: 'rgba(255,255,255,0.03)',
|
||||
margin: '8px 0',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
style={{ fontSize: 12, color: '#98a3af', padding: '6px 8px' }}
|
||||
>
|
||||
Generators
|
||||
</div>
|
||||
<div
|
||||
className="llm-item"
|
||||
onClick={() => startFetch(9, 'title')}
|
||||
>
|
||||
📄 Title Generator
|
||||
</div>
|
||||
<div
|
||||
className="llm-item"
|
||||
onClick={() => startFetch(10, 'abstract')}
|
||||
>
|
||||
📋 Abstract Generator
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chat panel */}
|
||||
{anchorShown && panelMode === 'chat' && (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="llm-panel"
|
||||
style={{
|
||||
top: panelRect.top,
|
||||
left: panelRect.left,
|
||||
width: panelRect.width,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<div className="llm-input-card">
|
||||
<div className="llm-badge">AI</div>
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
className="llm-input"
|
||||
placeholder="Ask AI for help"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={onInputKeyDown}
|
||||
rows={1}
|
||||
/>
|
||||
<button
|
||||
className="llm-send"
|
||||
onClick={() => startFetch(0, 'chat')}
|
||||
title="Send"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
className="llm-close"
|
||||
onClick={() => {
|
||||
setPanelMode('hidden')
|
||||
setAnchorShown(false)
|
||||
setQuery('')
|
||||
}}
|
||||
title="Close"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="llm-paraphrase-card">
|
||||
<div style={{ fontSize: 13, color: '#9fb0c6' }}>
|
||||
{kindTitleMap['chat']}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<Spinner />
|
||||
<div style={{ marginLeft: 10, color: '#9aa4b2' }}>
|
||||
Waiting for AI...
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
minHeight: 88,
|
||||
maxHeight: contentMaxHeight,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
paddingRight: 8,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="llm-result-html"
|
||||
dangerouslySetInnerHTML={{ __html: renderedHtml }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="llm-paraphrase-footer">
|
||||
<button
|
||||
className="llm-btn llm-primary"
|
||||
onClick={() => {
|
||||
setPanelMode('hidden')
|
||||
setQuery('')
|
||||
setResult('')
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="llm-btn llm-primary"
|
||||
onClick={() => insertCodeAfterSelection(result)}
|
||||
disabled={!result}
|
||||
>
|
||||
Insert
|
||||
</button>
|
||||
<button
|
||||
className="llm-btn llm-primary"
|
||||
onClick={() => startFetch(0, 'chat')}
|
||||
disabled={loading || !query.trim()}
|
||||
>
|
||||
Regenerate
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Paraphrase panel */}
|
||||
{panelMode === 'paraphrase' && (
|
||||
<div
|
||||
className="llm-panel"
|
||||
style={{
|
||||
top: panelRect.top,
|
||||
left: panelRect.left,
|
||||
width: panelRect.width,
|
||||
}}
|
||||
>
|
||||
<div className="llm-paraphrase-card">
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 13, color: '#9fb0c6' }}>
|
||||
{kindTitleMap[kind]}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
className="llm-btn"
|
||||
onClick={() => copyToClipboard(result)}
|
||||
>
|
||||
📋 Copy
|
||||
</button>
|
||||
<button
|
||||
className="llm-btn"
|
||||
onClick={() => setEditMode(s => !s)}
|
||||
>
|
||||
✏️ {editMode ? 'Done' : 'Edit'}
|
||||
</button>
|
||||
{kind !== 'title' && kind !== 'abstract' && (
|
||||
<button
|
||||
className="llm-btn"
|
||||
onClick={() => setShowDiff(s => !s)}
|
||||
>
|
||||
{showDiff ? 'Hide' : 'Track'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<Spinner />
|
||||
<div style={{ marginLeft: 10, color: '#9aa4b2' }}>
|
||||
Waiting for AI...
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{editMode ? (
|
||||
<textarea
|
||||
ref={editRef}
|
||||
value={result}
|
||||
onChange={e => setResult(e.target.value)}
|
||||
style={{
|
||||
width: '100%',
|
||||
minHeight: 88,
|
||||
maxHeight: contentMaxHeight,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
padding: 10,
|
||||
borderRadius: 8,
|
||||
background: 'rgba(255,255,255,0.02)',
|
||||
color: '#e6eef8',
|
||||
outline: 'none',
|
||||
resize: 'vertical',
|
||||
boxSizing: 'border-box',
|
||||
border: 'none',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ minHeight: 88 }}>
|
||||
<div
|
||||
style={{
|
||||
maxHeight: contentMaxHeight,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
paddingRight: 8,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="llm-result-html"
|
||||
style={{ color: '#e6eef8' }}
|
||||
dangerouslySetInnerHTML={{ __html: renderedHtml }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="llm-paraphrase-footer">
|
||||
<button
|
||||
className="llm-btn llm-primary"
|
||||
onClick={() => {
|
||||
setPanelMode('hidden')
|
||||
setAnchorShown(false)
|
||||
setResult('')
|
||||
setQuery('')
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
{kind === 'title' || kind === 'abstract' ? (
|
||||
<button
|
||||
className="llm-btn llm-primary"
|
||||
onClick={() => insertCodeAfterSelection(result)}
|
||||
disabled={loading || !result}
|
||||
>
|
||||
Insert
|
||||
</button>
|
||||
) : kind === 'summarize' || kind === 'explain' ? null : (
|
||||
<button
|
||||
className="llm-btn llm-primary"
|
||||
onClick={() => replaceSelectionWith(result)}
|
||||
disabled={loading || !result}
|
||||
>
|
||||
Replace
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="llm-btn llm-primary"
|
||||
onClick={() => startFetch(kindToMode[kind], kind)}
|
||||
disabled={loading}
|
||||
>
|
||||
Regenerate
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
LLMToolbar.displayName = 'LLMToolbar'
|
||||
export default LLMToolbar
|
||||
@@ -0,0 +1,111 @@
|
||||
import { memo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import OLTooltip from '@/shared/components/ol/ol-tooltip'
|
||||
import OLIconButton from '@/shared/components/ol/ol-icon-button'
|
||||
import { useLLMChat } from '../hooks/use-llm-chat'
|
||||
|
||||
interface LogEntry {
|
||||
key?: string
|
||||
level: string
|
||||
message?: string
|
||||
content?: string
|
||||
file?: string
|
||||
line?: number
|
||||
raw?: string
|
||||
}
|
||||
|
||||
interface AskAIButtonProps {
|
||||
logEntry?: LogEntry
|
||||
id?: string
|
||||
}
|
||||
|
||||
function PdfLogEntryAskAIButton({ logEntry }: AskAIButtonProps) {
|
||||
const { t } = useTranslation()
|
||||
const { modelsLoaded, hasModels } = useLLMChat()
|
||||
|
||||
const handleAskAI = useCallback(async () => {
|
||||
if (!logEntry) return
|
||||
try {
|
||||
const errorMessage = formatErrorForLLM(logEntry)
|
||||
|
||||
// Open the LLM chat rail tab first
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('ui:select-rail-tab', {
|
||||
detail: { tab: 'llm-chat', open: true },
|
||||
})
|
||||
)
|
||||
|
||||
// Small delay to let the rail panel mount and the hook register its listener
|
||||
setTimeout(() => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('llm-chat-send-message', {
|
||||
detail: { message: errorMessage },
|
||||
})
|
||||
)
|
||||
}, 150)
|
||||
} catch (err) {
|
||||
console.error('[LLM] Failed to send error to AI:', err)
|
||||
}
|
||||
}, [logEntry])
|
||||
|
||||
// logEntry may be undefined in certain contexts; only show for errors
|
||||
if (!logEntry || logEntry.level !== 'error') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!modelsLoaded || !hasModels) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<OLTooltip
|
||||
id={`ask-ai-${logEntry.key}`}
|
||||
description={t('ask_ai_about_error', 'Ask AI about this error')}
|
||||
overlayProps={{ placement: 'bottom' }}
|
||||
>
|
||||
<OLIconButton
|
||||
onClick={handleAskAI}
|
||||
variant="ghost"
|
||||
icon="smart_toy"
|
||||
accessibilityLabel={t(
|
||||
'ask_ai_about_error',
|
||||
'Ask AI about this error'
|
||||
)}
|
||||
/>
|
||||
</OLTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function formatErrorForLLM(logEntry: LogEntry): string {
|
||||
const parts = [
|
||||
'🔴 **LaTeX Compilation Error**',
|
||||
'',
|
||||
'**Error Message:**',
|
||||
logEntry.message || logEntry.content || 'Unknown error',
|
||||
'',
|
||||
]
|
||||
|
||||
if (logEntry.file) {
|
||||
parts.push(`**File:** \`${logEntry.file}\``)
|
||||
}
|
||||
|
||||
if (logEntry.line) {
|
||||
parts.push(`**Line:** ${logEntry.line}`)
|
||||
}
|
||||
|
||||
if (logEntry.raw && logEntry.raw !== logEntry.message) {
|
||||
parts.push('', '**Full Error Details:**', '```', logEntry.raw, '```', '')
|
||||
}
|
||||
|
||||
parts.push(
|
||||
'**Please help me:**',
|
||||
"1. Explain what this error means in simple terms",
|
||||
"2. Show me exactly what's wrong in my code",
|
||||
'3. Provide the corrected code',
|
||||
'4. Explain how to avoid this error in the future'
|
||||
)
|
||||
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
export default memo(PdfLogEntryAskAIButton)
|
||||
@@ -0,0 +1,6 @@
|
||||
// Extension point wrapper for sourceEditorExtensions
|
||||
// Provides the CodeMirror inline completion extension.
|
||||
// Must be exported as a function (options) => Extension to match the consumer contract.
|
||||
import { inlineCompletionExtension } from '../components/llm-completion'
|
||||
|
||||
export const extension = (_options: Record<string, any>) => inlineCompletionExtension()
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { ImperativePanelHandle } from 'react-resizable-panels'
|
||||
|
||||
export const useLLMChatPane = () => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [resizing, setResizing] = useState(false)
|
||||
const panelRef = useRef<ImperativePanelHandle>(null)
|
||||
|
||||
const togglePane = useCallback(() => {
|
||||
setIsOpen(value => !value)
|
||||
}, [])
|
||||
|
||||
return {
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
panelRef,
|
||||
resizing,
|
||||
setResizing,
|
||||
togglePane,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import getMeta from '@/utils/meta'
|
||||
|
||||
interface Message {
|
||||
role: 'system' | 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
interface LLMModel {
|
||||
id: string
|
||||
name: string
|
||||
isDefault: boolean
|
||||
isPersonal?: boolean
|
||||
label?: string
|
||||
}
|
||||
|
||||
interface LLMResponse {
|
||||
choices: Array<{
|
||||
message: {
|
||||
content: string
|
||||
}
|
||||
}>
|
||||
}
|
||||
|
||||
const SYSTEM_PROMPT = `You are an expert LaTeX debugging assistant and compiler error specialist.
|
||||
|
||||
**Your Primary Role - Error Debugging:**
|
||||
- Analyze LaTeX compilation errors and warnings
|
||||
- Identify syntax mistakes, missing packages, and structural issues
|
||||
- Explain errors in beginner-friendly language
|
||||
- Provide working fixes with clear explanations
|
||||
|
||||
**When a user sends a compilation error:**
|
||||
|
||||
1. **Quick Summary** (1-2 sentences)
|
||||
- What's wrong in plain English
|
||||
|
||||
2. **The Problem**
|
||||
- Explain the error clearly
|
||||
- Point to the exact issue in their code
|
||||
|
||||
3. **The Fix**
|
||||
- Show corrected code in \`\`\`latex blocks
|
||||
- Highlight what changed
|
||||
|
||||
4. **Why This Happened**
|
||||
- Brief explanation of the root cause
|
||||
- How to prevent it in future
|
||||
|
||||
**Error Analysis Guidelines:**
|
||||
- The line marked with → is where the error occurred
|
||||
- Look at surrounding context for clues
|
||||
- Common issues: typos in commands, missing packages, unmatched braces
|
||||
- Check for: \\begin without \\end, missing $, wrong package names
|
||||
|
||||
**Also Helpful With:**
|
||||
- General LaTeX syntax and commands
|
||||
- Document structure and formatting
|
||||
- Mathematical typesetting
|
||||
- Bibliography and citations
|
||||
|
||||
**Response Style:**
|
||||
- Be concise and practical
|
||||
- Use code blocks for all LaTeX examples
|
||||
- Assume the user is learning LaTeX
|
||||
- Focus on solving the immediate problem first
|
||||
|
||||
Remember: The user is likely frustrated. Be encouraging and clear!`
|
||||
|
||||
export const useLLMChat = () => {
|
||||
const projectId = getMeta('ol-project_id')
|
||||
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{
|
||||
role: 'system',
|
||||
content: SYSTEM_PROMPT,
|
||||
},
|
||||
])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [models, setModels] = useState<LLMModel[]>([])
|
||||
const [selectedModel, setSelectedModel] = useState<string>('')
|
||||
const [lastUserMessage, setLastUserMessage] = useState<string>('')
|
||||
const [modelsLoaded, setModelsLoaded] = useState(false)
|
||||
const [modelsError, setModelsError] = useState(false)
|
||||
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
|
||||
const messagesRef = useRef(messages)
|
||||
useEffect(() => {
|
||||
messagesRef.current = messages
|
||||
}, [messages])
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchModels() {
|
||||
if (!projectId) {
|
||||
console.warn('[LLMChat] No project ID available, skipping model fetch')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`/project/${projectId}/llm/models`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`[LLMChat] Models endpoint returned ${response.status}`)
|
||||
}
|
||||
const data = await response.json()
|
||||
|
||||
const { llmAllowUserSettings } = getMeta('ol-ExposedSettings') || {}
|
||||
let modelsFromBackend: LLMModel[] = data.models || []
|
||||
if (!llmAllowUserSettings) {
|
||||
modelsFromBackend = modelsFromBackend.filter(
|
||||
(m: LLMModel) =>
|
||||
!m.isPersonal && !(m.id && m.id.startsWith('personal-'))
|
||||
)
|
||||
}
|
||||
|
||||
setModels(modelsFromBackend)
|
||||
setModelsError(false)
|
||||
|
||||
const defaultModel =
|
||||
modelsFromBackend.find((m: LLMModel) => m.isDefault) ||
|
||||
modelsFromBackend[0]
|
||||
setSelectedModel(defaultModel?.id || '')
|
||||
setModelsLoaded(true)
|
||||
} catch (err) {
|
||||
console.error('[LLMChat] Failed to fetch models:', err)
|
||||
setModels([])
|
||||
setSelectedModel('')
|
||||
setModelsError(true)
|
||||
// Don't set modelsLoaded=true on error so button remains visible
|
||||
}
|
||||
}
|
||||
|
||||
fetchModels()
|
||||
}, [projectId])
|
||||
|
||||
const sendMessage = useCallback(
|
||||
async (userMessage: string) => {
|
||||
const newMessages: Message[] = [
|
||||
...messagesRef.current,
|
||||
{ role: 'user', content: userMessage },
|
||||
]
|
||||
|
||||
setMessages(newMessages)
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
setLastUserMessage(userMessage)
|
||||
|
||||
abortControllerRef.current = new AbortController()
|
||||
|
||||
try {
|
||||
const url = `/project/${projectId}/llm/chat`
|
||||
const csrfToken = getMeta('ol-csrfToken')
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrfToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messages: newMessages,
|
||||
model: selectedModel,
|
||||
}),
|
||||
signal: abortControllerRef.current.signal,
|
||||
credentials: 'same-origin',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
const data: LLMResponse = await response.json()
|
||||
|
||||
if (!data.choices || !data.choices[0]) {
|
||||
throw new Error('Invalid response format from LLM API')
|
||||
}
|
||||
|
||||
const assistantMessage: Message = {
|
||||
role: 'assistant',
|
||||
content: data.choices[0].message.content,
|
||||
}
|
||||
|
||||
setMessages([...newMessages, assistantMessage])
|
||||
} catch (err: any) {
|
||||
if (err.name === 'AbortError') {
|
||||
const abortMsg: Message = {
|
||||
role: 'assistant',
|
||||
content: '⚠️ Request stopped by user.',
|
||||
}
|
||||
setMessages([...newMessages, abortMsg])
|
||||
} else {
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : 'Unknown error'
|
||||
setError(errorMessage)
|
||||
|
||||
const errorMsg: Message = {
|
||||
role: 'assistant',
|
||||
content: `❌ Error: ${errorMessage}\n\nPlease check the console for details.`,
|
||||
}
|
||||
setMessages([...newMessages, errorMsg])
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
abortControllerRef.current = null
|
||||
}
|
||||
},
|
||||
[projectId, selectedModel]
|
||||
)
|
||||
|
||||
const stopGeneration = useCallback(() => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const rerunLastMessage = useCallback(() => {
|
||||
if (!lastUserMessage) return
|
||||
|
||||
const currentMessages = messagesRef.current
|
||||
let foundIndex = -1
|
||||
|
||||
for (let i = currentMessages.length - 1; i >= 0; i--) {
|
||||
if (
|
||||
currentMessages[i].role === 'user' &&
|
||||
currentMessages[i].content === lastUserMessage
|
||||
) {
|
||||
foundIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (foundIndex === -1) {
|
||||
sendMessage(lastUserMessage)
|
||||
return
|
||||
}
|
||||
|
||||
const messagesBeforeRerun = currentMessages.slice(0, foundIndex)
|
||||
setMessages(messagesBeforeRerun)
|
||||
|
||||
setTimeout(() => {
|
||||
sendMessage(lastUserMessage)
|
||||
}, 50)
|
||||
}, [lastUserMessage, sendMessage])
|
||||
|
||||
const clearMessages = useCallback(() => {
|
||||
setMessages([
|
||||
{
|
||||
role: 'system',
|
||||
content: SYSTEM_PROMPT,
|
||||
},
|
||||
])
|
||||
setError(null)
|
||||
setLastUserMessage('')
|
||||
}, [])
|
||||
|
||||
// Listen for messages from error log "Ask AI" buttons
|
||||
useEffect(() => {
|
||||
const handleSendMessage = (event: CustomEvent<{ message: string }>) => {
|
||||
sendMessage(event.detail.message)
|
||||
}
|
||||
|
||||
window.addEventListener(
|
||||
'llm-chat-send-message',
|
||||
handleSendMessage as EventListener
|
||||
)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
'llm-chat-send-message',
|
||||
handleSendMessage as EventListener
|
||||
)
|
||||
}
|
||||
}, [sendMessage])
|
||||
|
||||
return {
|
||||
messages,
|
||||
isLoading,
|
||||
error,
|
||||
sendMessage,
|
||||
stopGeneration,
|
||||
rerunLastMessage,
|
||||
clearMessages,
|
||||
models,
|
||||
selectedModel,
|
||||
setSelectedModel,
|
||||
canRerun: !!lastUserMessage,
|
||||
modelsLoaded,
|
||||
modelsError,
|
||||
hasModels: models.length > 0,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { renderInReactLayout } from '@/react'
|
||||
import '@/utils/meta'
|
||||
import '@/utils/webpack-public-path'
|
||||
import '@/infrastructure/error-reporter'
|
||||
import '@/i18n'
|
||||
import LLMAdminSettingsPage from '../../components/llm-admin-settings-page'
|
||||
|
||||
renderInReactLayout('llm-admin-settings-root', () => <LLMAdminSettingsPage />)
|
||||
@@ -0,0 +1,8 @@
|
||||
import { renderInReactLayout } from '@/react'
|
||||
import '@/utils/meta'
|
||||
import '@/utils/webpack-public-path'
|
||||
import '@/infrastructure/error-reporter'
|
||||
import '@/i18n'
|
||||
import LLMSettingsPage from '../../components/llm-settings-page'
|
||||
|
||||
renderInReactLayout('settings-page-root', () => <LLMSettingsPage />)
|
||||
@@ -0,0 +1,666 @@
|
||||
// LLM Chat styling matching the regular chat theme
|
||||
:root {
|
||||
--llm-chat-bg: var(--neutral-80);
|
||||
--llm-chat-color: var(--white);
|
||||
--llm-chat-text-color: var(--neutral-90);
|
||||
--llm-chat-instructions-color: var(--neutral-20);
|
||||
--llm-chat-new-message-bg: var(--neutral-70);
|
||||
--llm-chat-new-message-textarea-color: var(--neutral-90);
|
||||
--llm-chat-new-message-textarea-bg: var(--neutral-20);
|
||||
--llm-chat-new-message-textarea-border: var(--editor-border-color);
|
||||
--llm-chat-new-message-border: var(--editor-border-color);
|
||||
--llm-chat-message-user-bg: var(--green-70);
|
||||
--llm-chat-message-user-text: var(--white);
|
||||
--llm-chat-message-assistant-bg: var(--bg-light-secondary);
|
||||
--llm-chat-message-assistant-text: var(--neutral-90);
|
||||
--llm-chat-code-bg: rgba(0, 0, 0, 0.1);
|
||||
--llm-chat-code-text: var(--neutral-90);
|
||||
|
||||
/* LLM-specific fallbacks for suggestion items and selector */
|
||||
--llm-chat-suggestions-bg: var(--bg-light-secondary, #f3f4f6);
|
||||
--llm-chat-suggestions-color: var(--content-primary, #111);
|
||||
--llm-chat-suggestions-title-color: var(--content-primary, #111);
|
||||
--llm-chat-suggestion-item-bg: var(--llm-chat-new-message-textarea-bg, #fff);
|
||||
--llm-chat-suggestion-item-color: var(--content-primary, #111);
|
||||
--llm-model-selector-label-color: var(--content-primary, #111);
|
||||
--llm-model-selector-select-color: var(--content-primary, #111);
|
||||
/* Max width used by suggestions and model selector */
|
||||
--llm-suggestions-max-width: 350px;
|
||||
}
|
||||
|
||||
.ide-redesign-main {
|
||||
--llm-chat-bg: var(--bg-dark-primary);
|
||||
--llm-chat-color: var(--content-primary);
|
||||
--llm-chat-text-color: var(--content-primary);
|
||||
--llm-chat-instructions-color: var(--content-primary-dark);
|
||||
--llm-chat-new-message-bg: var(--bg-dark-primary);
|
||||
--llm-chat-new-message-textarea-color: var(--content-primary-dark);
|
||||
--llm-chat-new-message-textarea-bg: var(--bg-dark-primary);
|
||||
--llm-chat-message-user-bg: var(--green-70);
|
||||
--llm-chat-message-user-text: var(--white);
|
||||
--llm-chat-message-assistant-bg: var(--bg-light-secondary);
|
||||
--llm-chat-message-assistant-text: var(--content-primary);
|
||||
--llm-chat-code-bg: rgba(0, 0, 0, 0.2);
|
||||
--llm-chat-code-text: var(--content-primary);
|
||||
|
||||
/* Dark-theme LLM overrides */
|
||||
--llm-chat-suggestions-bg: var(--bg-dark-secondary, #111827);
|
||||
--llm-chat-suggestions-color: var(--content-primary, #e6eef7);
|
||||
--llm-chat-suggestions-title-color: var(--content-primary, #e6eef7);
|
||||
--llm-chat-suggestion-item-bg: var(--llm-chat-new-message-textarea-bg, #111827);
|
||||
--llm-chat-suggestion-item-color: var(--content-primary, #e6eef7);
|
||||
--llm-model-selector-label-color: var(--content-primary, #e6eef7);
|
||||
--llm-model-selector-select-color: var(--content-primary, #e6eef7);
|
||||
}
|
||||
|
||||
.llm-rail-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
.chat {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
.llm-chat-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
background-color: var(--llm-chat-bg);
|
||||
color: var(--llm-chat-color);
|
||||
|
||||
/* collaborator-style wrapper used when embedding MessageInput in AI pane */
|
||||
.llm-chat-collab-wrapper {
|
||||
margin-top: var(--spacing-03);
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
overflow: auto;
|
||||
resize: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure direct children don't overflow the container */
|
||||
>* {
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
align-items: center;
|
||||
padding: var(--spacing-03) var(--spacing-04) var(--spacing-03) var(--spacing-02);
|
||||
background-color: var(--llm-chat-new-message-bg);
|
||||
border-bottom: 1px solid var(--llm-chat-new-message-border);
|
||||
gap: var(--spacing-03);
|
||||
}
|
||||
|
||||
/* Header for LLM chat: model selector on the left, action buttons to the right */
|
||||
.llm-chat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-03);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.llm-model-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-03);
|
||||
/* Keep selector left-aligned and constrain width */
|
||||
flex: 0 1 auto;
|
||||
max-width: var(--llm-suggestions-max-width);
|
||||
min-width: 0;
|
||||
/* allow shrinking in narrow containers */
|
||||
|
||||
label {
|
||||
font-size: var(--font-size-02);
|
||||
font-weight: 600;
|
||||
color: var(--content-primary-themed);
|
||||
margin: 0 0 var(--spacing-00) var(--spacing-02);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Styles for the select control inside the model selector */
|
||||
select {
|
||||
padding: var(--spacing-02) var(--spacing-03);
|
||||
padding-right: calc(var(--spacing-03) + 18px);
|
||||
/* room for caret */
|
||||
border: 1px solid var(--llm-chat-new-message-textarea-border);
|
||||
border-radius: var(--border-radius-base);
|
||||
background-color: var(--llm-chat-new-message-textarea-bg, #fff);
|
||||
/* ensure the option text is readable in all themes */
|
||||
color: var(--content-primary-themed);
|
||||
font-size: var(--font-size-02);
|
||||
outline: none;
|
||||
border-color: var(--border-primary);
|
||||
min-width: 0;
|
||||
width: 200px;
|
||||
flex: 0 1 auto;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
background-image: linear-gradient(45deg, transparent 50%, currentColor 50%), linear-gradient(135deg, currentColor 50%, transparent 50%);
|
||||
background-position: calc(100% - 12px) calc(1em + 2px), calc(100% - 7px) calc(1em + 2px);
|
||||
background-size: 6px 6px, 6px 6px;
|
||||
background-repeat: no-repeat;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--border-primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Action buttons container
|
||||
.llm-action-buttons {
|
||||
display: flex;
|
||||
gap: var(--spacing-02);
|
||||
align-items: center;
|
||||
margin-left: auto;
|
||||
/* push actions to the far right */
|
||||
}
|
||||
|
||||
.llm-action-button {
|
||||
background-color: var(--llm-action-button-bg, transparent);
|
||||
border: 1px solid var(--llm-chat-new-message-textarea-border);
|
||||
padding: var(--spacing-02);
|
||||
cursor: pointer;
|
||||
border-radius: var(--border-radius-base);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--content-primary-themed);
|
||||
/* ensure high-contrast icon color */
|
||||
opacity: 1;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--bg-light-tertiary);
|
||||
color: var(--content-primary-themed);
|
||||
border-color: var(--border-secondary);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--border-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.material-symbols {
|
||||
font-size: 18px;
|
||||
color: inherit;
|
||||
/* inherit high-contrast color */
|
||||
}
|
||||
}
|
||||
|
||||
.llm-chat-welcome {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
/* Use flex so the welcome panel shares space and doesn't push the input offscreen */
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
||||
.llm-welcome-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--bg-secondary-themed);
|
||||
color: var(--content-primary-themed);
|
||||
font-size: 36px;
|
||||
margin-bottom: 20px;
|
||||
animation: float 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.llm-welcome-icon.material-symbols {
|
||||
font-size: 36px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: var(--font-size-02);
|
||||
font-weight: 600;
|
||||
color: var(--content-primary);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
>p {
|
||||
font-size: var(--font-size-02);
|
||||
color: var(--content-primary-themed);
|
||||
margin-bottom: 30px;
|
||||
max-width: 300px;
|
||||
line-height: var(--line-height-02);
|
||||
}
|
||||
|
||||
.llm-suggestions {
|
||||
background-color: var(--llm-chat-suggestions-bg);
|
||||
border-radius: var(--border-radius-large);
|
||||
padding: 20px;
|
||||
margin-bottom: 30px;
|
||||
max-width: var(--llm-suggestions-max-width);
|
||||
width: 100%;
|
||||
|
||||
.llm-suggestions-title {
|
||||
font-weight: 600;
|
||||
color: var(--content-primary-themed);
|
||||
margin-bottom: 12px;
|
||||
font-size: var(--font-size-02);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
|
||||
li {
|
||||
padding: var(--spacing-03) var(--spacing-04);
|
||||
margin-bottom: var(--spacing-02);
|
||||
background-color: var(--llm-chat-suggestion-item-bg);
|
||||
border-radius: var(--border-radius-base);
|
||||
border: 1px solid var(--llm-chat-new-message-textarea-border);
|
||||
font-size: var(--font-size-02);
|
||||
line-height: var(--line-height-02);
|
||||
color: var(--content-primary-themed);
|
||||
transition: all 0.2s ease;
|
||||
cursor: default;
|
||||
word-break: break-word;
|
||||
|
||||
/* keep hover a no-op so suggestions don't change color on hover */
|
||||
&:hover {
|
||||
background-color: var(--llm-chat-suggestion-item-bg);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.llm-status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-03);
|
||||
padding: var(--spacing-03) var(--spacing-05);
|
||||
background-color: var(--bg-warning-light);
|
||||
border: 1px solid var(--border-warning);
|
||||
border-radius: 20px;
|
||||
color: var(--content-warning);
|
||||
font-size: var(--font-size-02);
|
||||
font-weight: 500;
|
||||
|
||||
.material-symbols {
|
||||
font-size: 18px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Chat messages container
|
||||
.llm-chat-messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
background-color: var(--llm-chat-bg);
|
||||
padding: calc(var(--line-height-03) / 2);
|
||||
|
||||
.llm-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-01);
|
||||
margin-bottom: var(--line-height-03);
|
||||
|
||||
.message-container {
|
||||
display: flex;
|
||||
max-width: 100%;
|
||||
flex-wrap: wrap;
|
||||
/* allow content and metadata to wrap rather than overflow */
|
||||
|
||||
.message-content {
|
||||
background-color: var(--llm-chat-message-assistant-bg);
|
||||
border-radius: var(--border-radius-large);
|
||||
padding: var(--spacing-04) var(--spacing-05);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow-x: hidden;
|
||||
/* hide overflow; pre will scroll internally */
|
||||
word-break: break-word;
|
||||
|
||||
/* prevent long words from overflowing */
|
||||
p {
|
||||
margin-bottom: calc(var(--line-height-03) / 2);
|
||||
white-space: pre-wrap;
|
||||
/* wrap long paragraphs inside the message content */
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-weight: 600;
|
||||
margin-top: var(--spacing-04);
|
||||
margin-bottom: var(--spacing-02);
|
||||
color: var(--llm-chat-message-assistant-text);
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: var(--font-size-03);
|
||||
}
|
||||
|
||||
ul,
|
||||
ol {
|
||||
margin-left: var(--spacing-05);
|
||||
margin-bottom: calc(var(--line-height-03) / 2);
|
||||
|
||||
li {
|
||||
margin-bottom: var(--spacing-01);
|
||||
}
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: var(--llm-chat-code-bg);
|
||||
padding: 2px 6px;
|
||||
border-radius: var(--border-radius-base-sm);
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
font-size: 0.9em;
|
||||
color: var(--llm-chat-code-text);
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: var(--neutral-90);
|
||||
border-radius: var(--border-radius-base);
|
||||
padding: var(--spacing-04);
|
||||
margin: var(--spacing-03) 0;
|
||||
overflow-x: auto;
|
||||
max-width: 100%;
|
||||
|
||||
code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: var(--white);
|
||||
display: block;
|
||||
white-space: pre;
|
||||
/* preserve whitespace but allow scrolling */
|
||||
overflow-x: auto;
|
||||
word-break: normal;
|
||||
}
|
||||
}
|
||||
|
||||
hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-secondary);
|
||||
margin: var(--spacing-04) 0;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
em {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--link-primary);
|
||||
text-decoration: underline;
|
||||
|
||||
&:hover {
|
||||
color: var(--link-primary-hover);
|
||||
}
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 3px solid var(--border-primary);
|
||||
padding-left: var(--spacing-04);
|
||||
margin: var(--spacing-03) 0;
|
||||
color: var(--content-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
margin: var(--spacing-03) 0;
|
||||
width: 100%;
|
||||
|
||||
th,
|
||||
td {
|
||||
border: 1px solid var(--border-secondary);
|
||||
padding: var(--spacing-02) var(--spacing-03);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: var(--bg-light-tertiary);
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.llm-message-user .message-container {
|
||||
align-self: flex-end;
|
||||
justify-content: flex-end;
|
||||
|
||||
.message-content {
|
||||
background-color: var(--llm-chat-message-user-bg);
|
||||
color: var(--llm-chat-message-user-text);
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
color: var(--llm-chat-message-user-text);
|
||||
}
|
||||
|
||||
code {
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
pre {
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
|
||||
code {
|
||||
background: none;
|
||||
color: var(--white);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.llm-message-assistant .message-container {
|
||||
align-self: flex-start;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
// Loading indicator with stop button
|
||||
.llm-message-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-03);
|
||||
padding: var(--spacing-03) var(--spacing-05);
|
||||
background-color: var(--llm-chat-message-assistant-bg);
|
||||
border-radius: var(--border-radius-large);
|
||||
font-style: italic;
|
||||
color: var(--llm-chat-message-assistant-text);
|
||||
|
||||
.loading-icon {
|
||||
animation: spin 2s linear infinite;
|
||||
color: var(--llm-chat-message-assistant-text);
|
||||
}
|
||||
|
||||
span {
|
||||
flex: 1;
|
||||
color: var(--llm-chat-message-assistant-text);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop button styling
|
||||
.llm-stop-button {
|
||||
background-color: var(--alert-danger-bg);
|
||||
border: 1px solid var(--alert-danger-border);
|
||||
color: var(--alert-danger);
|
||||
padding: var(--spacing-02) var(--spacing-04);
|
||||
border-radius: var(--border-radius-base);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-02);
|
||||
font-size: var(--font-size-01);
|
||||
font-weight: 600;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--alert-danger);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.material-symbols {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
// Input form
|
||||
.llm-chat-input-form {
|
||||
$new-message-height: 80px;
|
||||
|
||||
height: $new-message-height;
|
||||
background-color: var(--llm-chat-new-message-bg);
|
||||
padding: calc(var(--line-height-03) / 4);
|
||||
border-top: 1px solid var(--llm-chat-new-message-border);
|
||||
display: flex;
|
||||
gap: var(--spacing-03);
|
||||
align-items: center;
|
||||
|
||||
.llm-chat-textarea {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
resize: none;
|
||||
border-radius: var(--border-radius-base);
|
||||
border: 1px solid var(--llm-chat-new-message-textarea-border);
|
||||
height: 100%;
|
||||
color: var(--llm-chat-new-message-textarea-color);
|
||||
font-size: var(--font-size-02);
|
||||
padding: calc(var(--line-height-03) / 4);
|
||||
background-color: var(--llm-chat-new-message-textarea-bg);
|
||||
font-family: inherit;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
.llm-chat-send-button {
|
||||
padding: var(--spacing-03) var(--spacing-05);
|
||||
background-color: var(--bg-accent-01);
|
||||
color: var(--white);
|
||||
border: none;
|
||||
border-radius: var(--border-radius-base);
|
||||
cursor: pointer;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 60px;
|
||||
transition: background-color 0.2s ease;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background-color: var(--bg-accent-02);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.material-symbols {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0px);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
// Spin animation for loading icon
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import Settings from '@overleaf/settings'
|
||||
import logger from '@overleaf/logger'
|
||||
|
||||
let LLMModule = {}
|
||||
|
||||
// LLM module is enabled when LLM_ENABLED env is set or llm.enabled in settings
|
||||
const llmEnabled =
|
||||
process.env.LLM_ENABLED === 'true' ||
|
||||
(Settings.llm && Settings.llm.enabled !== false)
|
||||
|
||||
logger.info(
|
||||
{
|
||||
LLM_ENABLED: process.env.LLM_ENABLED,
|
||||
LLM_API_URL: process.env.LLM_API_URL ? '(set)' : '(not set)',
|
||||
LLM_API_KEY: process.env.LLM_API_KEY ? '(set)' : '(not set)',
|
||||
LLM_MODEL_NAME: process.env.LLM_MODEL_NAME,
|
||||
LLM_ALLOW_USER_SETTINGS: process.env.LLM_ALLOW_USER_SETTINGS,
|
||||
settingsLlm: Settings.llm,
|
||||
llmEnabled,
|
||||
},
|
||||
'[LLM] Module init: environment check'
|
||||
)
|
||||
|
||||
if (llmEnabled) {
|
||||
const { default: LLMRouter } = await import('./app/src/LLMRouter.mjs')
|
||||
|
||||
// Configure LLM settings from environment
|
||||
Settings.llm = Settings.llm || {}
|
||||
Settings.llm.enabled = true
|
||||
Settings.llm.allowUserSettings =
|
||||
process.env.LLM_ALLOW_USER_SETTINGS === 'true' ||
|
||||
(Settings.llm.allowUserSettings ?? true)
|
||||
|
||||
logger.info(
|
||||
{
|
||||
llmSettings: Settings.llm,
|
||||
routerType: typeof LLMRouter,
|
||||
hasApply: typeof LLMRouter?.apply,
|
||||
},
|
||||
'[LLM] Module loaded successfully'
|
||||
)
|
||||
|
||||
LLMModule = {
|
||||
name: 'llm',
|
||||
router: LLMRouter,
|
||||
}
|
||||
} else {
|
||||
logger.info({}, '[LLM] Module NOT loaded (disabled)')
|
||||
}
|
||||
|
||||
export default LLMModule
|
||||
@@ -55,4 +55,5 @@ export type ExposedSettings = {
|
||||
githubSyncEnabled: boolean
|
||||
zoteroEnabled: boolean
|
||||
enablePandocConversions: boolean
|
||||
llmAllowUserSettings?: boolean
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user