242 lines
26 KiB
TypeScript
242 lines
26 KiB
TypeScript
import { Channel, invoke } from '@tauri-apps/api/core';
|
||
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api';
|
||
import { marked } from 'marked';
|
||
import DOMPurify from 'dompurify';
|
||
|
||
type Audience = { id: string; name: string; instructions: string };
|
||
type Settings = { base_url: string; model: string; api_key_env: string; context_bytes: number; max_output_tokens: number; token_parameter: string; stream: boolean; selected_audience: string; audiences: Audience[] };
|
||
type Source = { id: string; role: string; path: string; start_line: number; end_line: number; enclosing: string; code: string; truncated: boolean };
|
||
type Callee = { is_concept: boolean; is_standard: boolean; id: string; location: { path: string; label: string; range: { start: { line: number; character: number } } } };
|
||
type Prepared = { concept_dependencies: boolean; include_standard: boolean; available_callees: Callee[]; selected_callees: string[]; recursion_depth: number; prompt_budget: number; id: number; endpoint: string; model: string; audience: string; prompt_bytes: number; messages: { role: string; content: string }[]; bundle: { selected_path: string; sources: Source[]; usages: unknown[]; references_found: number; callers_found: number; callees_found: number; omissions: string[]; diagnostics: string[] } };
|
||
type Options = { workspace: () => { root: string } | null; path: () => string; epoch: () => number; ready: () => Promise<unknown>; open: (path: string, hit: { path: string; line: number; column: number; end_column: number }, semantic?: boolean) => Promise<void> };
|
||
const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
|
||
export function installExplanations(editor: monaco.editor.IStandaloneCodeEditor, options: Options) {
|
||
const pane = document.createElement('section'); pane.id = 'explanation-pane'; pane.hidden = true;
|
||
pane.innerHTML = `<div class="explanation-heading"><strong>Explain code</strong><button id="model-settings">Model settings</button><button id="close-explanation" aria-label="Close explanation panel">×</button></div>
|
||
<div class="explanation-content"><p id="explain-target" class="hint">Select C/C++ code, then prepare context.</p>
|
||
<label for="explain-question">Question (optional)</label><textarea id="explain-question" rows="2" placeholder="What does this code do, and how do its callers use it?"></textarea>
|
||
<div class="explanation-actions"><button id="prepare-context">Prepare context</button><button id="send-explanation" class="primary" disabled>Send explanation</button><button id="cancel-explanation" hidden>Cancel</button></div>
|
||
<fieldset id="callee-options" hidden><legend id="dependency-heading">Called-function bodies</legend><label id="include-standard-label" class="reset-label" hidden><input id="include-standard" type="checkbox">Include std namespace concepts (including nested dependencies)</label><label for="callee-depth">Recursion depth</label><select id="callee-depth"><option value="0">0 — No additional bodies</option><option value="1">1 — Selected direct callees</option><option value="2">2 — Selected callees and their callees</option></select><div class="explanation-actions"><button id="callees-all">Select all</button><button id="callees-none">Select none</button></div><div id="callee-list"></div><button id="apply-callees">Apply body selection</button><p class="hint">Depth is capped at 2. Shared dependencies are deduplicated. Definitions remain subject to the prompt and snapshot limits.</p></fieldset>
|
||
<p id="context-size-warning" class="hint" role="status"></p>
|
||
<p id="explain-status" class="hint" role="status">Preparation stays local. Sending uses the endpoint you configure.</p>
|
||
<p id="explain-destination" class="hint"></p><details id="context-details"><summary>Inspect context</summary><div id="context-summary"></div><div id="context-sources"></div><details><summary>Exact request messages</summary><pre id="context-messages"></pre></details></details>
|
||
<p id="citation-status" class="hint" role="status"></p><article id="explanation-answer" aria-label="Model explanation"></article></div>`;
|
||
document.querySelector('main')!.append(pane);
|
||
const dialog = document.createElement('dialog'); dialog.id = 'llm-settings';
|
||
dialog.innerHTML = `<form id="llm-form"><h2>Model settings</h2><p class="hint">Saved for this project. Use an OpenAI-compatible Chat Completions endpoint.</p>
|
||
<div class="model-presets"><button id="preset-local" type="button">Local endpoint</button><button id="preset-openai" type="button">OpenAI endpoint</button></div>
|
||
<label for="llm-url">API base URL</label><input id="llm-url" required placeholder="http://localhost:1234/v1">
|
||
<label for="llm-model">Model identifier</label><input id="llm-model" placeholder="Model name supplied by your server">
|
||
<label for="llm-key-env">API key environment variable (optional)</label><input id="llm-key-env" placeholder="OPENAI_API_KEY" autocomplete="off">
|
||
<label for="llm-key">API key (current session only)</label><input id="llm-key" type="password" autocomplete="off" placeholder="Leave blank to retain the current session key"><label class="reset-label"><input id="clear-llm-key" type="checkbox">Clear session key</label><p id="key-state" class="hint"></p>
|
||
<div class="model-grid"><div><label for="llm-budget">Prompt budget (KiB)</label><input id="llm-budget" type="number" min="16" max="256" required></div><div><label for="llm-output">Output token limit</label><input id="llm-output" type="number" min="128" max="32768" required></div></div>
|
||
<label for="llm-token-parameter">Output limit compatibility</label><select id="llm-token-parameter"><option value="max_completion_tokens">max_completion_tokens (OpenAI)</option><option value="max_tokens">max_tokens (older compatible servers)</option><option value="omit">Let the server choose</option></select><label class="reset-label"><input id="llm-stream" type="checkbox">Stream the explanation as it is generated</label>
|
||
<label for="llm-audience">Audience profile</label><select id="llm-audience"></select><label for="llm-instructions">Audience instructions (editable for each profile)</label><textarea id="llm-instructions" rows="4" required></textarea>
|
||
<p id="llm-error" class="error" role="alert"></p><div class="dialog-actions"><button id="llm-cancel" type="button">Cancel</button><button id="llm-save" type="submit" class="primary">Save settings</button></div></form>`;
|
||
document.body.append(dialog);
|
||
let prepared: Prepared | null = null, text = '', generation = 0, busy = false;
|
||
let questionRevision = 0, questionPending = false, bodiesDirty = false;
|
||
let questionTimer: ReturnType<typeof setTimeout> | undefined;
|
||
let settings: Settings | null = null, profile = '', renderTimer: ReturnType<typeof setTimeout> | undefined;
|
||
function show() { pane.dispatchEvent(new Event('cex-show-chat')); pane.hidden = false; document.querySelector('main')!.classList.add('explaining'); editor.layout(); }
|
||
function setBusy(value: boolean) {
|
||
busy = value; $<HTMLButtonElement>('prepare-context').disabled = value;
|
||
$<HTMLButtonElement>('send-explanation').disabled = value || !prepared || questionPending || bodiesDirty;
|
||
$<HTMLFieldSetElement>('callee-options').disabled = value;
|
||
$<HTMLButtonElement>('apply-callees').disabled = value || questionPending;
|
||
$('cancel-explanation').hidden = !value; $<HTMLButtonElement>('model-settings').disabled = value;
|
||
$<HTMLTextAreaElement>('explain-question').disabled = value;
|
||
}
|
||
function clearContext() { ++questionRevision; clearTimeout(questionTimer); questionPending = false; bodiesDirty = false; $('callee-options').hidden = true; $('context-size-warning').textContent = ''; prepared = null; $<HTMLButtonElement>('send-explanation').disabled = true; $('context-summary').replaceChildren(); $('context-sources').replaceChildren(); $('context-messages').textContent = ''; $('explain-destination').textContent = ''; }
|
||
async function citation(source: Source) {
|
||
await options.open(source.path, { path: source.path, line: source.start_line, column: 1, end_column: 1 }, true);
|
||
const current = editor.getModel()?.getLinesContent().slice(source.start_line - 1, source.end_line).join('\n');
|
||
$('citation-status').textContent = current === source.code ? `Opened ${source.path}:${source.start_line}.` : 'This file differs from the reviewed snapshot or could not be opened. Inspect context to see the exact evidence sent.';
|
||
}
|
||
function renderAnswer() {
|
||
const html = marked.parse(text, { async: false });
|
||
$('explanation-answer').innerHTML = DOMPurify.sanitize(html, { FORBID_TAGS: ['img', 'svg', 'math', 'iframe', 'object', 'form', 'input', 'button', 'video', 'audio'], FORBID_ATTR: ['style', 'src', 'srcset'] });
|
||
// Model-supplied URLs are text only. Only source IDs from our reviewed bundle
|
||
// become actionable links; citations cannot grant arbitrary file access.
|
||
$('explanation-answer').querySelectorAll('a').forEach(a => a.replaceWith(document.createTextNode(a.textContent ?? '')));
|
||
const walker = document.createTreeWalker($('explanation-answer'), NodeFilter.SHOW_TEXT);
|
||
const nodes: Text[] = [];
|
||
while (walker.nextNode()) { const node = walker.currentNode as Text; if (!node.parentElement?.closest('pre,code')) nodes.push(node); }
|
||
for (const node of nodes) {
|
||
const matches = [...node.data.matchAll(/\[S[1-9]\d*\]/g)]; if (!matches.length) continue;
|
||
const fragment = document.createDocumentFragment(); let offset = 0;
|
||
for (const match of matches) {
|
||
fragment.append(node.data.slice(offset, match.index));
|
||
const id = match[0].slice(1, -1), source = prepared?.bundle.sources.find(s => s.id === id);
|
||
if (source) { const button = document.createElement('button'); button.className = 'source-citation'; button.textContent = match[0]; button.title = `${source.path}:${source.start_line}-${source.end_line}`; button.onclick = () => void citation(source); fragment.append(button); }
|
||
else { const unknown = document.createElement('span'); unknown.textContent = `${match[0]} (unknown source)`; unknown.className = 'error'; fragment.append(unknown); }
|
||
offset = match.index! + match[0].length;
|
||
}
|
||
fragment.append(node.data.slice(offset)); node.replaceWith(fragment);
|
||
}
|
||
}
|
||
function renderContext(value: Prepared, controls = true) {
|
||
const fraction = value.prompt_bytes / value.prompt_budget;
|
||
$('context-size-warning').textContent = `${(value.prompt_bytes / 1024).toFixed(1)} KiB / ${(value.prompt_budget / 1024).toFixed(0)} KiB prompt budget. ${fraction >= 0.75 || value.prompt_bytes >= 32768 ? 'Large context: sending this much code can increase latency and cost and leave less model capacity for the answer.' : 'More included bodies increase request size and generation cost.'}${value.bundle.sources.some(s => s.truncated) ? ' Some bodies are truncated; inspect the snapshots.' : ''}`;
|
||
if (controls) {
|
||
bodiesDirty = false;
|
||
$('callee-options').hidden = false;
|
||
$('apply-callees').textContent = value.concept_dependencies ? 'Apply concept selection' : 'Apply body selection';
|
||
$('dependency-heading').textContent = value.concept_dependencies ? 'Concept dependencies' : 'Called-function bodies';
|
||
$('include-standard-label').hidden = !value.concept_dependencies;
|
||
$<HTMLInputElement>('include-standard').checked = value.include_standard ?? false;
|
||
const depthOptions = $<HTMLSelectElement>('callee-depth').options;
|
||
depthOptions[0].text = '0 — No additional definitions';
|
||
depthOptions[1].text = value.concept_dependencies ? '1 — Selected concepts' : '1 — Selected direct callees';
|
||
depthOptions[2].text = value.concept_dependencies ? '2 — Selected concepts and their dependencies' : '2 — Selected callees and their callees';
|
||
$<HTMLSelectElement>('callee-depth').value = String(value.recursion_depth);
|
||
$('callee-list').replaceChildren();
|
||
for (const callee of value.available_callees) {
|
||
const label = document.createElement('label'), input = document.createElement('input');
|
||
input.type = 'checkbox'; input.value = callee.id; input.dataset.standard = String(callee.is_standard ?? false); input.disabled = callee.is_standard && !value.include_standard; input.checked = !input.disabled && value.selected_callees.includes(callee.id);
|
||
input.onchange = bodiesChanged;
|
||
label.append(input, ` ${callee.location.label || 'Dependency'}${callee.is_standard ? ' [std]' : ''} — ${callee.location.path}:${callee.location.range.start.line + 1}`);
|
||
$('callee-list').append(label);
|
||
}
|
||
if (!value.available_callees.length) $('callee-list').textContent = value.concept_dependencies ? 'No referenced concepts found in this constraint.' : 'No direct callees found for this selection.';
|
||
}
|
||
$('explain-target').textContent = value.bundle.selected_path;
|
||
$('explain-destination').textContent = `Send to ${value.endpoint} · model: ${value.model || '(not configured)'} · audience: ${value.audience}`;
|
||
const summary = document.createElement('p'); summary.className = 'hint';
|
||
summary.textContent = `${value.bundle.sources.length} snapshots · ${value.bundle.references_found} references · ${value.bundle.callers_found} callers · ${value.concept_dependencies ? `${value.available_callees.length} concept dependencies` : `${value.bundle.callees_found} callees`} · ${(value.prompt_bytes / 1024).toFixed(1)} KiB of prompt messages (bytes, not tokens).`;
|
||
$('context-summary').replaceChildren(summary);
|
||
for (const message of [...value.bundle.omissions, ...value.bundle.diagnostics]) { const p = document.createElement('p'); p.className = 'hint'; p.textContent = message; $('context-summary').append(p); }
|
||
$('context-sources').replaceChildren();
|
||
for (const source of value.bundle.sources) {
|
||
const details = document.createElement('details'), title = document.createElement('summary'), pre = document.createElement('pre');
|
||
title.textContent = `[${source.id}] ${source.role}: ${source.path}:${source.start_line}-${source.end_line}${source.enclosing ? ` · ${source.enclosing}` : ''}${source.truncated ? ' (truncated)' : ''}`;
|
||
pre.textContent = source.code.split('\n').map((line, i) => `${source.start_line + i}: ${line}`).join('\n');
|
||
details.append(title, pre); $('context-sources').append(details);
|
||
}
|
||
$('context-messages').textContent = JSON.stringify(value.messages, null, 2);
|
||
}
|
||
async function prepareContext() {
|
||
const workspace = options.workspace(), path = options.path();
|
||
if (!workspace || !path || editor.getModel()?.getLanguageId() !== 'cpp') { $('explain-status').textContent = 'Open a C/C++ file and select a symbol first.'; return; }
|
||
show(); const operation = ++generation, epoch = options.epoch();
|
||
const selected = editor.getSelection();
|
||
const position = selected && !selected.isEmpty() ? selected.getStartPosition() : editor.getPosition()!;
|
||
const selection = selected && !selected.isEmpty() ? { start: { line: selected.startLineNumber - 1, character: selected.startColumn - 1 }, end: { line: selected.endLineNumber - 1, character: selected.endColumn - 1 } } : null;
|
||
clearContext(); text = ''; $('explanation-answer').replaceChildren(); $('citation-status').textContent = '';
|
||
$('explain-status').textContent = 'Gathering compiler-backed context locally…'; setBusy(true);
|
||
try {
|
||
await options.ready();
|
||
if (operation !== generation || epoch !== options.epoch()) return;
|
||
const value = await invoke<Prepared>('prepare_explanation', { root: workspace.root, path, position: { line: position.lineNumber - 1, character: position.column - 1 }, selection, question: $<HTMLTextAreaElement>('explain-question').value });
|
||
if (operation !== generation || epoch !== options.epoch()) return;
|
||
prepared = value; renderContext(value); $<HTMLDetailsElement>('context-details').open = true;
|
||
$('explain-status').textContent = 'Context ready. Review the evidence and destination, then send.';
|
||
} catch (error) { if (operation === generation) $('explain-status').textContent = String(error); }
|
||
finally { if (operation === generation) setBusy(false); }
|
||
}
|
||
function bodiesChanged() {
|
||
bodiesDirty = true;
|
||
if ($<HTMLSelectElement>('callee-depth').value === '0' && $('callee-list').querySelector('input:checked')) $<HTMLSelectElement>('callee-depth').value = '1';
|
||
setBusy(busy); $('explain-status').textContent = 'Dependency selection changed. Apply it to update the context and size preview.';
|
||
}
|
||
$('callee-depth').onchange = () => { bodiesDirty = true; setBusy(busy); $('explain-status').textContent = 'Apply body selection to update the preview.'; };
|
||
$('include-standard').onchange = () => {
|
||
const include = $<HTMLInputElement>('include-standard').checked;
|
||
$('callee-list').querySelectorAll<HTMLInputElement>('input[data-standard="true"]').forEach(input => { input.disabled = !include; if (!include) input.checked = false; });
|
||
bodiesChanged();
|
||
};
|
||
$('callees-all').onclick = () => { $('callee-list').querySelectorAll<HTMLInputElement>('input').forEach(input => input.checked = !input.disabled); bodiesChanged(); };
|
||
$('callees-none').onclick = () => { $('callee-list').querySelectorAll<HTMLInputElement>('input').forEach(input => input.checked = false); bodiesChanged(); };
|
||
$('apply-callees').onclick = async () => {
|
||
const workspace = options.workspace(); if (!workspace || !prepared || busy || questionPending) return;
|
||
const operation = ++generation, epoch = options.epoch(); setBusy(true);
|
||
$('explain-status').textContent = 'Collecting selected dependency definitions locally…';
|
||
try {
|
||
const value = await invoke<Prepared>('expand_explanation', { root: workspace.root, id: prepared.id, selected: [...$('callee-list').querySelectorAll<HTMLInputElement>('input:checked')].map(input => input.value), depth: Number($<HTMLSelectElement>('callee-depth').value), includeStandard: $<HTMLInputElement>('include-standard').checked, question: $<HTMLTextAreaElement>('explain-question').value });
|
||
if (operation !== generation || epoch !== options.epoch()) return;
|
||
prepared = value; text = ''; renderAnswer(); renderContext(value);
|
||
$('explain-status').textContent = 'Body selection applied. Review the size and any omissions before sending.';
|
||
} catch (error) { if (operation === generation) $('explain-status').textContent = String(error); }
|
||
finally { if (operation === generation) setBusy(false); }
|
||
};
|
||
$('explain-question').oninput = () => {
|
||
clearTimeout(questionTimer); const revision = ++questionRevision;
|
||
if (!prepared) return;
|
||
questionPending = true; setBusy(busy);
|
||
$('explain-status').textContent = 'Updating prompt using the existing source snapshots…';
|
||
questionTimer = setTimeout(async () => {
|
||
const workspace = options.workspace(), context = prepared; if (!workspace || !context) return;
|
||
try {
|
||
const value = await invoke<Prepared>('update_explanation_question', { root: workspace.root, id: context.id, question: $<HTMLTextAreaElement>('explain-question').value });
|
||
if (revision !== questionRevision) return;
|
||
prepared = value; renderContext(value, false); questionPending = false; setBusy(busy);
|
||
$('explain-status').textContent = 'Prompt updated. Existing source snapshots are ready to send.';
|
||
} catch (error) { if (revision === questionRevision) $('explain-status').textContent = String(error); }
|
||
}, 200);
|
||
};
|
||
dialog.addEventListener('cancel', () => { $<HTMLInputElement>('llm-key').value = ''; });
|
||
$('prepare-context').onclick = () => void prepareContext();
|
||
$('nav-explain').onclick = () => { show(); if (!busy) void prepareContext(); };
|
||
editor.addAction({ id: 'cex.explain', label: 'Explain Selected Code…', contextMenuGroupId: 'navigation', contextMenuOrder: 2, run: () => { show(); if (!busy) return prepareContext(); } });
|
||
$('send-explanation').onclick = async () => {
|
||
const workspace = options.workspace(); if (!workspace || !prepared || busy || questionPending || bodiesDirty) return;
|
||
const operation = ++generation, epoch = options.epoch(), id = prepared.id;
|
||
setBusy(true); text = ''; $('explanation-answer').replaceChildren();
|
||
$('explain-status').textContent = `Requesting explanation from ${prepared.endpoint}…`;
|
||
const onDelta = new Channel<string>();
|
||
onDelta.onmessage = delta => {
|
||
if (operation !== generation || epoch !== options.epoch()) return;
|
||
text += delta;
|
||
if (renderTimer === undefined) renderTimer = setTimeout(() => { renderTimer = undefined; renderAnswer(); }, 80);
|
||
};
|
||
try {
|
||
const result = await invoke<{ text: string; finish_reason: string }>('send_explanation', { root: workspace.root, id, onDelta });
|
||
if (operation !== generation || epoch !== options.epoch()) return;
|
||
text = result.text; renderAnswer();
|
||
$('explain-status').textContent = result.finish_reason === 'length' ? 'Output limit reached; this explanation may be incomplete.' : result.finish_reason === 'content_filter' ? 'The provider filtered this response; it may be incomplete.' : 'Explanation complete. Source citations open the reviewed locations.';
|
||
} catch (error) { if (operation === generation) { $('explain-status').textContent = `${String(error)}${text ? ' Text shown is partial.' : ''}`; renderAnswer(); } }
|
||
finally { if (operation === generation) setBusy(false); }
|
||
};
|
||
$('cancel-explanation').onclick = () => {
|
||
++generation; const workspace = options.workspace();
|
||
if (workspace) void invoke('cancel_explanation', { root: workspace.root }).catch(error => { $('explain-status').textContent = String(error); });
|
||
setBusy(false); renderAnswer(); $('explain-status').textContent = `Cancelled.${text ? ' Text shown is partial.' : ''}`;
|
||
};
|
||
$('close-explanation').onclick = () => { pane.hidden = true; document.querySelector('main')!.classList.remove('explaining'); editor.layout(); };
|
||
function audienceInstructions() { if (settings) { const a = settings.audiences.find(a => a.id === profile); if (a) a.instructions = $<HTMLTextAreaElement>('llm-instructions').value; } }
|
||
$('llm-audience').onchange = () => { audienceInstructions(); profile = $<HTMLSelectElement>('llm-audience').value; $<HTMLTextAreaElement>('llm-instructions').value = settings?.audiences.find(a => a.id === profile)?.instructions ?? ''; };
|
||
$('model-settings').onclick = async () => {
|
||
const workspace = options.workspace(); if (!workspace) { $('explain-status').textContent = 'Open a project first.'; return; }
|
||
const epoch = options.epoch();
|
||
try {
|
||
const result = await invoke<{ settings: Settings; has_session_key: boolean }>('get_llm_settings', { root: workspace.root }); if (epoch !== options.epoch()) return;
|
||
settings = result.settings;
|
||
$<HTMLInputElement>('llm-url').value = settings.base_url; $<HTMLInputElement>('llm-model').value = settings.model;
|
||
$<HTMLInputElement>('llm-key-env').value = settings.api_key_env; $<HTMLInputElement>('llm-key').value = ''; $<HTMLInputElement>('clear-llm-key').checked = false;
|
||
$('key-state').textContent = result.has_session_key ? 'A key is set for this session and endpoint.' : 'No session key is set. Local endpoints may not require one.';
|
||
$<HTMLInputElement>('llm-budget').value = String(settings.context_bytes / 1024); $<HTMLInputElement>('llm-output').value = String(settings.max_output_tokens);
|
||
$<HTMLSelectElement>('llm-token-parameter').value = settings.token_parameter; $<HTMLInputElement>('llm-stream').checked = settings.stream;
|
||
$('llm-audience').replaceChildren(...settings.audiences.map(a => { const option = document.createElement('option'); option.value = a.id; option.textContent = a.name; return option; }));
|
||
profile = settings.selected_audience; $<HTMLSelectElement>('llm-audience').value = profile; $<HTMLTextAreaElement>('llm-instructions').value = settings.audiences.find(a => a.id === profile)!.instructions;
|
||
$('llm-error').textContent = ''; dialog.showModal();
|
||
} catch (error) { $('explain-status').textContent = String(error); }
|
||
};
|
||
$('preset-local').onclick = () => { $<HTMLInputElement>('llm-url').value = 'http://localhost:1234/v1'; $<HTMLInputElement>('llm-key-env').value = ''; $<HTMLInputElement>('clear-llm-key').checked = true; };
|
||
$('preset-openai').onclick = () => { $<HTMLInputElement>('llm-url').value = 'https://api.openai.com/v1'; $<HTMLInputElement>('llm-key-env').value = 'OPENAI_API_KEY'; $<HTMLInputElement>('clear-llm-key').checked = true; };
|
||
$('llm-cancel').onclick = () => { $<HTMLInputElement>('llm-key').value = ''; dialog.close(); };
|
||
$('llm-form').onsubmit = async event => {
|
||
event.preventDefault(); const workspace = options.workspace(); if (!workspace || !settings) return;
|
||
audienceInstructions();
|
||
settings = { ...settings, base_url: $<HTMLInputElement>('llm-url').value.trim(), model: $<HTMLInputElement>('llm-model').value.trim(), api_key_env: $<HTMLInputElement>('llm-key-env').value.trim(), context_bytes: Number($<HTMLInputElement>('llm-budget').value) * 1024, max_output_tokens: Number($<HTMLInputElement>('llm-output').value), token_parameter: $<HTMLSelectElement>('llm-token-parameter').value, stream: $<HTMLInputElement>('llm-stream').checked, selected_audience: profile };
|
||
const key = $<HTMLInputElement>('llm-key').value; const apiKey = key || ($<HTMLInputElement>('clear-llm-key').checked ? '' : null);
|
||
$<HTMLInputElement>('llm-key').value = ''; $<HTMLButtonElement>('llm-save').disabled = true;
|
||
try { await invoke('save_llm_settings', { root: workspace.root, settings, apiKey }); clearContext(); text = ''; renderAnswer(); dialog.close(); $('explain-status').textContent = 'Settings saved. Prepare context using the selected audience before sending.'; }
|
||
catch (error) { $('llm-error').textContent = String(error); }
|
||
finally { $<HTMLButtonElement>('llm-save').disabled = false; }
|
||
};
|
||
return {
|
||
reset() { ++generation; clearTimeout(renderTimer); renderTimer = undefined; clearContext(); text = ''; $('explanation-answer').replaceChildren(); $('citation-status').textContent = ''; $('explain-target').textContent = 'Select C/C++ code, then prepare context.'; $('explain-status').textContent = 'Preparation stays local. Sending uses the endpoint you configure.'; setBusy(false); dialog.close(); $<HTMLInputElement>('llm-key').value = ''; },
|
||
fileOpened() { $<HTMLButtonElement>('nav-explain').disabled = editor.getModel()?.getLanguageId() !== 'cpp'; },
|
||
};
|
||
}
|