Path: blob/master/src/packages/frontend/codemirror/extensions/ai-formula.tsx
5949 views
/*1* This file is part of CoCalc: Copyright © 2025 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45import { Button, Descriptions, Divider, Input, Modal, Space } from "antd";6import { debounce } from "lodash";7import { FormattedMessage, useIntl } from "react-intl";89import { useLanguageModelSetting } from "@cocalc/frontend/account/useLanguageModelSetting";10import {11redux,12useAsyncEffect,13useEffect,14useState,15useTypedRedux,16} from "@cocalc/frontend/app-framework";17import { Localize, useLocalizationCtx } from "@cocalc/frontend/app/localize";18import type { Message } from "@cocalc/frontend/client/types";19import {20HelpIcon,21Icon,22Markdown,23Paragraph,24Text,25Title,26} from "@cocalc/frontend/components";27import AIAvatar from "@cocalc/frontend/components/ai-avatar";28import { LLMModelName } from "@cocalc/frontend/components/llm-name";29import { useLLMHistory } from "@cocalc/frontend/frame-editors/llm/use-llm-history";30import { LLMHistorySelector } from "@cocalc/frontend/frame-editors/llm/llm-history-selector";31import LLMSelector from "@cocalc/frontend/frame-editors/llm/llm-selector";32import { dialogs, labels } from "@cocalc/frontend/i18n";33import { show_react_modal } from "@cocalc/frontend/misc";34import { LLMCostEstimation } from "@cocalc/frontend/misc/llm-cost-estimation";35import track from "@cocalc/frontend/user-tracking";36import { webapp_client } from "@cocalc/frontend/webapp-client";37import { isFreeModel } from "@cocalc/util/db-schema/llm-utils";38import { Locale } from "@cocalc/util/i18n";39import { unreachable } from "@cocalc/util/misc";4041type Mode = "tex" | "md";4243const LLM_USAGE_TAG = `generate-formula`;4445interface Opts {46mode: Mode;47text?: string;48project_id: string;49locale?: Locale;50}5152export async function ai_gen_formula({53mode,54text = "",55project_id,56locale,57}: Opts): Promise<string> {58return await show_react_modal((cb) => (59<Localize>60<AiGenFormula61mode={mode}62text={text}63project_id={project_id}64locale={locale}65cb={cb}66/>67</Localize>68));69}7071interface Props extends Opts {72cb: (err?: string, result?: string) => void;73}7475function AiGenFormula({ mode, text = "", project_id, locale, cb }: Props) {76const intl = useIntl();77const { setLocale } = useLocalizationCtx();78const is_cocalc_com = useTypedRedux("customize", "is_cocalc_com");79const [model, setModel] = useLanguageModelSetting(project_id);80const [input, setInput] = useState<string>(text);81const [formula, setFormula] = useState<string>("");82const [fullReply, setFullReply] = useState<string>("");83const [generating, setGenerating] = useState<boolean>(false);84const [error, setError] = useState<string | undefined>(undefined);85const [tokens, setTokens] = useState<number>(0);86const { prompts: historyPrompts, addPrompt } = useLLMHistory("formula");8788useEffect(() => {89if (typeof locale === "string") {90setLocale(locale);91}92}, [locale]);9394useAsyncEffect(95debounce(96async () => {97const { input, history, system } = getPrompt() ?? "";98// compute the number of tokens (this MUST be a lazy import):99const { getMaxTokens, numTokensEstimate } =100await import("@cocalc/frontend/misc/llm");101102const all = [103input,104history.map(({ content }) => content).join(" "),105system,106].join(" ");107setTokens(numTokensEstimate(all, getMaxTokens(model)));108},1091000,110{ leading: true, trailing: true },111),112113[model, input],114);115116const enabled = redux117.getStore("projects")118.hasLanguageModelEnabled(project_id, LLM_USAGE_TAG);119120function getSystemPrompt(): string {121const p1 = `Typeset the plain-text description of a mathematical formula as a LaTeX formula. The formula will be`;122const p2 = `Return only the LaTeX formula, ready to be inserted into the document. Do not add any explanations.`;123switch (mode) {124case "tex":125return `${p1} in a *.tex file. Assume the package "amsmath" is available. ${p2}`;126case "md":127return `${p1} in a markdown file. Formulas are inside of $ or $$. ${p2}`;128default:129unreachable(mode);130return p1;131}132}133134function getPrompt(): { input: string; history: Message[]; system: string } {135const system = getSystemPrompt();136// 3-shot examples137const history: Message[] = [138{ role: "user", content: "equation e^(i pi) = -1" },139{ role: "assistant", content: "$$e^{i \\pi} = -1$$" },140{141role: "user",142content: "integral 0 to 2 pi sin(x)^2",143},144{145role: "assistant",146content: "$\\int_{0}^{2\\pi} \\sin(x)^2 \\, \\mathrm{d}x$",147},148{149role: "user",150content: "equation system: [ 1 + x^2 = a, 1 - y^2 = ln(a) ]",151},152{153role: "assistant",154content:155"\\begin{cases}\n1 + x^2 = a \\\n1 - y^2 = \\ln(a)\n\\end{cases}",156},157];158return { input: input || text, system, history };159}160161function wrapFormula(tex: string = "") {162// wrap single-line formulas in $...$163// if it is multiline, wrap in \begin{equation}...\end{equation}164// but only wrap if actually necessary165tex = tex.trim();166if (tex.split("\n").length > 1) {167if (tex.includes("\\begin{")) {168return tex;169} else if (tex.startsWith("$$") && tex.endsWith("$$")) {170return tex;171} else {172return `\\begin{equation}\n${tex}\n\\end{equation}`;173}174} else {175if (tex.startsWith("$") && tex.endsWith("$")) {176return tex;177} else if (tex.startsWith("\\(") && tex.endsWith("\\)")) {178return tex;179} else {180return `$${tex}$`;181}182}183}184185function processFormula(formula: string): string {186let tex = "";187// iterate over all lines in formula. save everything between the first ``` and last ``` in tex188let inCode = false;189for (const line of formula.split("\n")) {190if (line.startsWith("```")) {191inCode = !inCode;192} else if (inCode) {193tex += line + "\n";194}195}196// we found nothing -> the entire formula string is the tex code197if (!tex) {198tex = formula;199}200// if there are "\[" and "\]" in the formula, replace both by $$201if (tex.includes("\\[") && tex.includes("\\]")) {202tex = tex.replace(/\\\[|\\\]/g, "$$");203}204// similar, replace "\(" and "\)" by single $ signs205if (tex.includes("\\(") && tex.includes("\\)")) {206tex = tex.replace(/\\\(|\\\)/g, "$");207}208// if there are at least two $$ or $ in the tex, we extract the part between the first and second $ or $$209// This is necessary, because despite the prompt, some LLM return stuff like: "Here is the LaTeX formula: $$ ... $$."210for (const delimiter of ["$$", "$"]) {211const parts = tex.split(delimiter);212if (parts.length >= 3) {213tex = parts[1];214break;215}216}217setFormula(tex);218return tex;219}220221async function doGenerate() {222try {223setError(undefined);224setGenerating(true);225setFormula("");226setFullReply("");227track("chatgpt", {228project_id,229tag: LLM_USAGE_TAG,230mode,231type: "generate",232model,233});234const { system, input, history } = getPrompt();235236// Add prompt to history before generating237addPrompt(input);238239const reply = await webapp_client.openai_client.query({240input,241history,242system,243model,244project_id,245tag: LLM_USAGE_TAG,246});247const tex = processFormula(reply);248// significant difference? Also show the full reply249if (reply.length > 2 * tex.length) {250setFullReply(reply);251} else {252setFullReply("");253}254} catch (err) {255setError(`${err}`);256} finally {257setGenerating(false);258}259}260261// Start the query immediately, if the user had selected some text … and it's a free model262useEffect(() => {263if (text && isFreeModel(model, is_cocalc_com)) {264doGenerate();265}266}, [text]);267268function renderTitle() {269return (270<>271<Title level={4}>272<AIAvatar size={20} />{" "}273<FormattedMessage274id="codemirror.extensions.ai_formula.title"275defaultMessage="Generate LaTeX Formula"276/>277</Title>278{enabled ? (279<>280{intl.formatMessage(dialogs.select_llm)}:{" "}281<LLMSelector282project_id={project_id}283model={model}284setModel={setModel}285/>286</>287) : undefined}288</>289);290}291292function renderContent() {293const help = (294<HelpIcon title="Usage" extra="Help">295<FormattedMessage296id="codemirror.extensions.ai_formula.help"297defaultMessage={`298<p>You can enter the description of your desired formula in various ways:</p>299<ul>300<li>natural language: <code>drake equation</code>,</li>301<li>simple algebraic notation: <code>(a+b)^2 = a^2 + 2 a b + b^2</code>,</li>302<li>or a combination of both: <code>integral from 0 to infinity of (1+sin(x))/x^2 dx</code>.</li>303</ul>304<p>If the formula is not quite right, click "Generate" once again, try a different language model, or adjust the description.305Of course, you can also edit it as usual after you have inserted it.</p>306<p>Once you're happy, click the "Insert formula" button and the generated LaTeX formula will be inserted at the current cursor position.307The "Insert fully reply" button will, well, insert the entire answer.</p>308<p>Prior to opening this dialog, you can even select a portion of your text.309This will be used as your description and the AI language model will be queried immediately.310Inserting the formula will then replace the selected text.</p>`}311values={{312p: (children: any) => <Paragraph>{children}</Paragraph>,313ul: (children: any) => <ul>{children}</ul>,314li: (children: any) => <li>{children}</li>,315code: (children: any) => <Text code>{children}</Text>,316}}317/>318</HelpIcon>319);320return (321<Space direction="vertical" size="middle" style={{ width: "100%" }}>322<Paragraph style={{ marginBottom: 0 }}>323<FormattedMessage324id="codemirror.extensions.ai_formula.description"325defaultMessage="The {model} language model will generate a LaTeX formula based on your description. {help}"326values={{327model: <LLMModelName model={model} size={18} />,328help,329}}330/>331</Paragraph>332<div style={{ textAlign: "right" }}>333<LLMCostEstimation334// limited to 200, since we only get a formula – which is not a lengthy text!335maxOutputTokens={200}336model={model}337tokens={tokens}338type="secondary"339/>340</div>341<Space.Compact style={{ width: "100%" }}>342<Input343allowClear344disabled={generating}345placeholder={intl.formatMessage({346id: "codemirror.extensions.ai_formula.input_placeholder",347defaultMessage:348"Describe the formula in natural language and/or algebraic notation.",349})}350value={input}351onChange={(e) => setInput(e.target.value)}352onPressEnter={doGenerate}353addonBefore={<Icon name="fx" />}354/>355<LLMHistorySelector356prompts={historyPrompts}357onSelect={setInput}358disabled={generating}359/>360<Button361disabled={!input.trim() || generating}362loading={generating}363onClick={doGenerate}364type={formula ? "default" : "primary"}365>366{intl.formatMessage(labels.generate)}367</Button>368</Space.Compact>369{formula ? (370<Descriptions371size={"small"}372column={1}373bordered374items={[375{376key: "1",377label: "LaTeX",378children: <Paragraph code>{formula}</Paragraph>,379},380{381key: "2",382label: "Preview",383children: <Markdown value={wrapFormula(formula)} />,384},385...(fullReply386? [387{388key: "3",389label: "Full reply",390children: <Markdown value={fullReply} />,391},392]393: []),394]}395/>396) : undefined}397{error ? <Paragraph type="danger">{error}</Paragraph> : undefined}398{mode === "tex" ? (399<>400<Divider />401<Paragraph type="secondary">402<FormattedMessage403id="codemirror.extensions.ai_formula.amsmath_note"404defaultMessage="Note: You might have to ensure that <code>{amsmath_package}</code> is loaded in the preamble."405values={{406code: (children: any) => <code>{children}</code>,407amsmath_package: "\\usepackage{amsmath}",408}}409/>410</Paragraph>411</>412) : undefined}413</Space>414);415}416417function renderButtons() {418return (419<Space.Compact>420<Button onClick={onCancel}>{intl.formatMessage(labels.cancel)}</Button>421<Button422type={"default"}423disabled={!fullReply}424onClick={() => cb(undefined, `\n\n${fullReply}\n\n`)}425>426{intl.formatMessage({427id: "codemirror.extensions.ai_formula.insert_full_reply_button",428defaultMessage: "Insert full reply",429})}430</Button>431<Button432type={formula ? "primary" : "default"}433disabled={!formula}434onClick={() => cb(undefined, wrapFormula(formula))}435>436{intl.formatMessage({437id: "codemirror.extensions.ai_formula.insert_formula_button",438defaultMessage: "Insert formula",439})}440</Button>441</Space.Compact>442);443}444445function renderBody() {446if (!enabled) {447return (448<div>449<FormattedMessage450id="codemirror.extensions.ai_formula.disabled_message"451defaultMessage="AI language models are disabled."452/>453</div>454);455}456return renderContent();457}458459function onCancel() {460cb(undefined, text);461}462463return (464<Modal465title={renderTitle()}466open467footer={renderButtons()}468onCancel={onCancel}469width={{ xs: "90vw", sm: "90vw", md: "80vw", lg: "70vw", xl: "60vw" }}470>471{renderBody()}472</Modal>473);474}475476477