diff --git a/src/packages/frontend/account/i18n-selector.tsx b/src/packages/frontend/account/i18n-selector.tsx index 1b278376dd0..b1ace4ea1bc 100644 --- a/src/packages/frontend/account/i18n-selector.tsx +++ b/src/packages/frontend/account/i18n-selector.tsx @@ -4,11 +4,20 @@ */ /* -Basically a drop-down to change the langauge (i18n localization) +Basically a drop-down to change the language (i18n localization) */ import { DownOutlined } from "@ant-design/icons"; -import { Button, Dropdown, MenuProps, Modal, Space, Tooltip } from "antd"; +import { + Button, + Dropdown, + MenuProps, + Modal, + Select, + SelectProps, + Space, + Tooltip, +} from "antd"; import { SizeType } from "antd/es/config-provider/SizeContext"; import { useState } from "react"; import { defineMessage, useIntl } from "react-intl"; @@ -54,6 +63,50 @@ Thank you for your patience and understanding as we work to make our application description: "Content of translation information modal", }); +interface LanguageSelectorProps + extends Omit { + value?: string; + onChange?: (language: string) => void; +} + +/** + * A reusable language selector component for translation purposes. + */ +export function LanguageSelector({ + value, + onChange, + ...props +}: LanguageSelectorProps) { + const intl = useIntl(); + + let availableLocales = Object.keys(LOCALIZATIONS) as Locale[]; + + const options = availableLocales.map((locale) => { + const localization = LOCALIZATIONS[locale]; + const other = + locale === value + ? localization.name + : intl.formatMessage(localization.trans); + return { + value: locale, + label: `${localization.flag} ${localization.native} (${other})`, + }; + }); + + return ( + onChange(e.target.value)} + onKeyDown={onKeyDown} + style={{ width: "100%" }} + /> + )} + + ); +} + +const ACTIONS_CODE: { [mode in CodeMode]: LLMTool } = { + ask: { + icon: "question-circle", + label: ASK_LABEL, + descr: defineMessage({ + id: "jupyter.llm.cell-tool.actions.ask.descr", + defaultMessage: + "Ask a custom question about this cell with optional context from surrounding cells.", + }), + prompt: ({ language, kernel_display, extra }) => + `Your task is to answer the following question about the ${jupyterCell({ + language, + kernel_display, + })}. Use the context from surrounding cells if provided to give a more comprehensive answer.\n\n${extra}`, + }, explain: { icon: "sound-outlined", label: defineMessage({ @@ -153,7 +253,7 @@ const ACTIONS: { [mode in Mode]: LLMTool } = { prompt: ({ language, stepByStep, kernel_display }) => `Your task is to give a ${ stepByStep ? `step-by-step explanation` : `short high-level summary` - } of the ${jupytercell({ language, kernel_display })}:`, + } of the ${jupyterCell({ language, kernel_display })}:`, }, bugfix: { icon: "clean-outlined", @@ -164,10 +264,10 @@ const ACTIONS: { [mode in Mode]: LLMTool } = { descr: defineMessage({ id: "jupyter.llm.cell-tool.actions.bugfix.descr", defaultMessage: - "Describe the problem of that cell in order to get a bugfixed version.", + "Describe the problem in the cell to get a bug-fixed version.", }), prompt: ({ language, extra, kernel_display }) => - `Your task is to analyze the ${jupytercell({ + `Your task is to analyze the ${jupyterCell({ language, kernel_display, })}. Identify any bugs or errors. Explain the problems you found in the original code and how your fixes address them.${ @@ -187,7 +287,7 @@ const ACTIONS: { [mode in Mode]: LLMTool } = { defaultMessage: "Modify the code in the cell", }), prompt: ({ language, extra, kernel_display }) => - `Your task is to modify the ${jupytercell({ + `Your task is to modify the ${jupyterCell({ language, kernel_display, })}. The modification is "${extra}"`, @@ -203,10 +303,10 @@ const ACTIONS: { [mode in Mode]: LLMTool } = { defaultMessage: "Improve the code in that cell.", }), prompt: ({ language, extra, kernel_display }) => - `Your task is to analyze the ${jupytercell({ + `Your task is to analyze the ${jupyterCell({ language, kernel_display, - })}. Identify any areas of improvments. The new code must be functional, efficient, and adhere to best practices. Explain how your code improves it.${ + })}. Identify any areas of improvements. The new code must be functional, efficient, and adhere to best practices. Explain how your code improves it.${ extra ? ` In particular, optimize this aspect: "${extra}"` : "" }`, }, @@ -223,7 +323,7 @@ const ACTIONS: { [mode in Mode]: LLMTool } = { defaultMessage: "Add documentation", }), prompt: ({ language, kernel_display }) => - `Your task is to add documentation to the ${jupytercell({ + `Your task is to add documentation to the ${jupyterCell({ language, kernel_display, })}. The new code must be exactly the same. Insert additional documentation comments and rewrite existing comments.`, @@ -255,10 +355,88 @@ const ACTIONS: { [mode in Mode]: LLMTool } = { }, } as const; -export function LLMCellTool({ actions, id, style, llmTools }: Props) { +const ACTIONS_MD: { [mode in MarkdownMode]: LLMTool } = { + ask: { + icon: "question-circle", + label: ASK_LABEL, + descr: defineMessage({ + id: "jupyter.llm.cell-tool.actions.md.ask.descr", + defaultMessage: + "Ask a custom question about this cell with optional context from surrounding cells.", + }), + prompt: ({ extra }) => + `Your task is to answer the following question about the provided Markdown content. Use the context from surrounding cells if provided to give a more comprehensive answer.\n\n${extra}`, + }, + document: { + icon: "book", + label: defineMessage({ + id: "jupyter.llm.cell-tool.actions.md.document.label", + defaultMessage: "Document", + description: + "Label on a button to write a documentation, i.e. to 'document' this", + }), + descr: defineMessage({ + id: "jupyter.llm.cell-tool.actions.md.document.descr", + defaultMessage: "Write a summary about all the cells in the context", + }), + prompt: ({ extra }) => + `Your task is to write comprehensive documentation based on the notebook context provided. Use the context from surrounding cells to understand the overall analysis or workflow, and enhance the current cell's content accordingly. ${ + extra ? `Focus specifically on: ${extra}` : "" + }`, + }, + proofread: { + icon: "check-circle", + label: defineMessage({ + id: "jupyter.llm.cell-tool.actions.md.proofread.label", + defaultMessage: "Proofread", + }), + descr: defineMessage({ + id: "jupyter.llm.cell-tool.actions.md.proofread.descr", + defaultMessage: "Enhance language, fix spelling and grammar errors.", + }), + prompt: () => + `Your task is to proofread the provided Markdown text. Fix spelling and grammar errors, improve clarity and readability, and enhance the overall language quality while preserving the original meaning and structure.`, + }, + formulize: { + icon: "fx", + label: defineMessage({ + id: "jupyter.llm.cell-tool.actions.md.formulize.label", + defaultMessage: "Add Formulas", + }), + descr: defineMessage({ + id: "jupyter.llm.cell-tool.actions.md.formulize.descr", + defaultMessage: + "Add mathematical formulas to make text more readable for scientists.", + }), + prompt: () => + `Your task is to enhance the provided Markdown text by adding appropriate mathematical formulas to make it more readable for scientists. Use LaTeX math notation with $...$ for inline formulas and $$...$$ for display formulas. You can also use equation environments when appropriate. Keep all original content but add relevant mathematical expressions where they would enhance understanding.`, + }, + translate_text: { + icon: "global", + label: defineMessage({ + id: "jupyter.llm.cell-tool.actions.md.translate-text.label", + defaultMessage: "Translate", + }), + descr: defineMessage({ + id: "jupyter.llm.cell-tool.actions.md.translate-text.descr", + defaultMessage: "Translate the text content to another human language.", + }), + prompt: ({ target = "Spanish" }) => + `Your task is to translate the provided Markdown text to ${target}. Preserve the Markdown formatting and structure, including any LaTeX formulas, code blocks, and other markup elements. Only translate the actual text content.`, + }, +} as const; + +export function LLMCellTool({ + actions, + id, + style, + llmTools, + cellType = "code", +}: Props) { const { actions: project_actions, onCoCalcCom } = useProjectContext(); const intl = useIntl(); const { project_id, path } = useFrameContext(); + const frameActions = useNotebookFrameActions(); const [isQuerying, setIsQuerying] = useState(false); const [error, setError] = useState(""); const [mode, setMode] = useState(null); @@ -267,37 +445,126 @@ export function LLMCellTool({ actions, id, style, llmTools }: Props) { const [extraModify, setExtraModify] = useState( MODIFICATIONS[0].value, ); - const [targetLangauge, setTargetLanguage] = + const [extraAsk, setExtraAsk] = useState(""); + const [targetLanguage, setTargetLanguage] = useState("Python"); const [otherLanguage, setOtherLanguage] = useState(""); + const [targetTextLanguage, setTargetTextLanguage] = useState("es"); // Default to Spanish const [includeOutput, setIncludeOutput] = useState(false); const [stepByStep, setStepByStep] = useState(true); const [message, setMessage] = useState(""); const [tokens, setTokens] = useState(0); + // Context selection for document mode + const [contextRange, setContextRange] = useState<[number, number]>([-2, 2]); + const [cellTypes, setCellTypes] = useState<"all" | "code">("all"); + const kernelLanguage = useMemo((): string => { const kernel_info = actions?.store.get("kernel_info"); return kernel_info?.get("language")?.toLowerCase() ?? "python"; }, [actions?.store.get("kernel_info")]); + const isMarkdownCell = cellType === "markdown"; + + const getAction = (mode: Mode): LLMTool => { + if (isMarkdownCell) { + return ACTIONS_MD[mode as MarkdownMode]; + } else { + return ACTIONS_CODE[mode as CodeMode]; + } + }; + + // Map language codes to full language names for translate_text mode + const getLanguageName = (code: string): string => { + return LOCALIZATIONS[code as keyof typeof LOCALIZATIONS]?.name ?? code; + }; + + const shouldShowContext = (): boolean => { + // Show context selection for: + // - ask mode (any cell type) + // - document mode (markdown cells only) + return mode === "ask" || (mode === "document" && isMarkdownCell); + }; + + const getContextContent = (): CellContextContent => { + if (!shouldShowContext()) return {}; + + // contextRange is like [-2, 2], so aboveCount should be 2, belowCount should be 2 + const aboveCount = Math.abs(contextRange[0]); + const belowCount = Math.abs(contextRange[1]); + + return getNonemptyCellContents({ + actions: frameActions.current, + id, + direction: "around", + cellCount: "all", // Use "all" for around direction + cellTypes, + lang: kernelLanguage, + aboveCount, + belowCount, + }); + }; + const extra = useMemo(() => { switch (mode) { + case "ask": + return extraAsk; case "bugfix": return extraBug; case "improve": return extraImprove; case "modify": return extraModify; + case "document": + return extraAsk; // For markdown document mode, reuse the ask input default: return ""; } - }, [mode, extraBug, extraImprove, extraModify]); + }, [mode, extraAsk, extraBug, extraImprove, extraModify]); + + const isSubmitDisabled = useMemo(() => { + if (mode == null) return true; + switch (mode) { + case "ask": + return !extraAsk.trim(); + case "bugfix": + return !extraBug.trim(); + case "improve": + return !extraImprove.trim(); + case "modify": + return !extraModify.trim(); + case "explain": + return false; + case "document": + return false; // Document mode should never be disabled + case "translate": + return targetLanguage === OTHER_LANG && !otherLanguage.trim(); + case "translate_text": + return !targetTextLanguage; + case "proofread": + case "formulize": + return false; + default: + unreachable(mode); + return false; + } + }, [ + mode, + extraAsk, + extraBug, + extraImprove, + extraModify, + targetLanguage, + otherLanguage, + targetTextLanguage, + isMarkdownCell, + ]); useEffect(() => { if (mode !== "translate") return; // we change the target language to R, if the cell language is python – otherwise target is python // we change the target language, if it is the same as the kernel language - if (targetLangauge.toLocaleLowerCase() === kernelLanguage) { + if (targetLanguage.toLocaleLowerCase() === kernelLanguage) { setTargetLanguage(kernelLanguage === "python" ? "R" : "Python"); } }, [mode, kernelLanguage]); @@ -314,9 +581,11 @@ export function LLMCellTool({ actions, id, style, llmTools }: Props) { llmTools?.model, includeOutput, extra, - targetLangauge, + targetLanguage, otherLanguage, stepByStep, + contextRange, + cellTypes, ]); // end of hooks @@ -385,11 +654,17 @@ export function LLMCellTool({ actions, id, style, llmTools }: Props) { const kernel_info = actions.store.get("kernel_info"); const language = kernel_info.get("language"); const kernel_display = kernel_info.get("display_name"); - const prompt = ACTIONS[mode].prompt({ + + const prompt = getAction(mode).prompt({ language, kernel_display, extra, - target: targetLangauge === OTHER_LANG ? otherLanguage : targetLangauge, + target: + mode === "translate_text" + ? getLanguageName(targetTextLanguage) + : targetLanguage === OTHER_LANG + ? otherLanguage + : targetLanguage, stepByStep, }); @@ -403,9 +678,42 @@ export function LLMCellTool({ actions, id, style, llmTools }: Props) { chunks.push(prompt); if (!preview) chunks.push(``); + + // Add context for ask and document modes (inside details) + if (shouldShowContext()) { + const contextContent = getContextContent(); + if (contextContent.before || contextContent.after) { + chunks.push("Context from surrounding cells:"); + + if (contextContent.before) { + chunks.push("Cells BEFORE current cell:"); + chunks.push(`\n${contextContent.before}\n`); + } + + if (contextContent.after) { + chunks.push("Cells AFTER current cell:"); + chunks.push(`\n${contextContent.after}\n`); + } + + chunks.push(""); // Add empty line for separation + } + } + const input = cell.get("input"); const delimI = backtickSequence(input); - chunks.push(`${delimI}${language}\n${input}\n${delimI}`); + + // For ask and document modes with context, label the current cell content + const contextContent = getContextContent(); + if ( + shouldShowContext() && + (contextContent.before || contextContent.after) + ) { + chunks.push("Current cell content:"); + } + + chunks.push( + `${delimI}${isMarkdownCell ? "markdown" : language}\n${input}\n${delimI}`, + ); if (includeOutput) { chunks.push("Output:"); const fullOutput = cellOutputToText(cell); @@ -430,12 +738,14 @@ export function LLMCellTool({ actions, id, style, llmTools }: Props) { } function renderDropdown() { + const actions = isMarkdownCell ? ACTIONS_MD : ACTIONS_CODE; + return ( ).map( + items: (Object.entries(actions) as Entries).map( ([mode, action]) => { return { key: mode, @@ -445,7 +755,7 @@ export function LLMCellTool({ actions, id, style, llmTools }: Props) { placement={"left"} > {" "} - {intl.formatMessage(action.label)} + {intl.formatMessage(action.label)}… ), onClick: () => setMode(mode as Mode), @@ -480,6 +790,15 @@ export function LLMCellTool({ actions, id, style, llmTools }: Props) { function renderExplanation() { if (mode == null) return null; switch (mode) { + case "ask": + return ( + + + + ); case "improve": return ( @@ -487,7 +806,7 @@ export function LLMCellTool({ actions, id, style, llmTools }: Props) { id="jupyter.llm.cell-tool.explanation.improve" defaultMessage={`The selected language model will analyze the code and suggest improvements. Beware, that the results are not guaranteed to be - correct, nor could cause subtle problmes – review them carefully.`} + correct, nor could cause subtle problems – review them carefully.`} /> ); @@ -528,8 +847,11 @@ export function LLMCellTool({ actions, id, style, llmTools }: Props) { ); @@ -545,35 +867,64 @@ export function LLMCellTool({ actions, id, style, llmTools }: Props) { /> ); + case "proofread": + return ( + + + + ); + case "formulize": + return ( + + + + ); + case "translate_text": + return ( + + + + ); default: unreachable(mode); return null; } } - function renderInput( - label: string, - placeholder: string, - extra: string, - setExtra: (s: string) => void, - ) { - if (mode == null) return; - return ( - - {label}: - setExtra(e.target.value)} - onKeyDown={handleKeyDown} - style={{ width: "100%" }} - /> - - ); - } - function renderControls() { switch (mode) { + case "ask": { + const label = intl.formatMessage({ + id: "jupyter.llm.cell-tool.ask.label", + defaultMessage: "Question", + }); + const placeholder = intl.formatMessage({ + id: "jupyter.llm.cell-tool.ask.placeholder", + defaultMessage: "What would you like to know about this code?", + }); + return ( + + ); + } + case "bugfix": { const label = intl.formatMessage({ id: "jupyter.llm.cell-tool.bugfix.label", @@ -583,7 +934,15 @@ export function LLMCellTool({ actions, id, style, llmTools }: Props) { id: "jupyter.llm.cell-tool.bugfix.placeholder", defaultMessage: "Describe the problem to fix…", }); - return renderInput(label, placeholder, extraBug, setExtraBug); + return ( + + ); } case "improve": { @@ -597,7 +956,13 @@ export function LLMCellTool({ actions, id, style, llmTools }: Props) { }); return ( <> - {renderInput(label, placeholder, extraImprove, setExtraImprove)} + @@ -630,7 +995,13 @@ export function LLMCellTool({ actions, id, style, llmTools }: Props) { }); return ( <> - {renderInput(label, placeholder, extraModify, setExtraModify)} + {MODIFICATIONS.map(({ label, value }) => ( Target language: ); + + case "translate_text": + return ( + + + Target language: + setTargetTextLanguage(lang)} + style={{ minWidth: "200px" }} + /> + + + ); + + case "document": + if (isMarkdownCell) { + const label = intl.formatMessage({ + id: "jupyter.llm.cell-tool.document.label", + defaultMessage: "Focus", + }); + const placeholder = intl.formatMessage({ + id: "jupyter.llm.cell-tool.document.placeholder", + defaultMessage: + "What should the documentation focus on? (optional)", + }); + return ( + + ); + } + return null; + + case "proofread": + case "formulize": + return null; // These modes don't need additional controls } return null; } + function renderContextSelection() { + if (!shouldShowContext()) return null; + + return ( + + ); + } + function renderContent() { if (mode == null || llmTools == null) return null; const { model } = llmTools; @@ -735,64 +1163,70 @@ export function LLMCellTool({ actions, id, style, llmTools }: Props) { > {renderExplanation()} {renderControls()} - {renderIncludeOutput(model)} + {renderContextSelection()} + {renderIncludeOutput()} + {renderPreviewLLM(model)} {renderFooter(model)} ); } - function renderIncludeOutput(model) { - if (llmTools == null) return; + function renderIncludeOutput() { + if (llmTools == null || isMarkdownCell) return; const output_label = defineMessage({ id: "jupyter.llm.cell-tool.include-output.label", defaultMessage: `{include, select, true {Include output} other {No output}}`, }); return ( - <> - - - setIncludeOutput(val)} - unCheckedChildren={intl.formatMessage(output_label, { - include: false, - })} - checkedChildren={intl.formatMessage(output_label, { - include: true, - })} + + + setIncludeOutput(val)} + unCheckedChildren={intl.formatMessage(output_label, { + include: false, + })} + checkedChildren={intl.formatMessage(output_label, { + include: true, + })} + /> + + + + - - - + + + + ); + } + + function renderPreviewLLM(model) { + if (llmTools == null) return; + return ( + - - - - - ), - children: ( - - ), - }, - ]} - /> - + ), + children: ( + + ), + }, + ]} + /> ); } @@ -842,7 +1276,12 @@ export function LLMCellTool({ actions, id, style, llmTools }: Props) { ? { language: kernelLanguage, target: - targetLangauge === OTHER_LANG ? otherLanguage : targetLangauge, + targetLanguage === OTHER_LANG ? otherLanguage : targetLanguage, + } + : null), + ...(mode === "translate_text" + ? { + target: getLanguageName(targetTextLanguage), } : null), }); @@ -875,7 +1314,7 @@ export function LLMCellTool({ actions, id, style, llmTools }: Props) { description={ "Operate on a specific cell in a Jupyter Notebook. task are words like 'Explain', 'Fix', 'Document', 'Describe', ..." } - values={{ task: intl.formatMessage(ACTIONS[mode].label) }} + values={{ task: intl.formatMessage(getAction(mode).label) }} />{" "} ( - + )} > diff --git a/src/packages/frontend/jupyter/util/cell-content.ts b/src/packages/frontend/jupyter/util/cell-content.ts new file mode 100644 index 00000000000..a2425272b18 --- /dev/null +++ b/src/packages/frontend/jupyter/util/cell-content.ts @@ -0,0 +1,162 @@ +/* + * This file is part of CoCalc: Copyright © 2024 Sagemath, Inc. + * License: MS-RSL – see LICENSE.md for details + */ + +import { NotebookFrameActions } from "@cocalc/frontend/frame-editors/jupyter-editor/cell-notebook/actions"; +import { CUTOFF } from "@cocalc/frontend/frame-editors/llm/consts"; +import { backtickSequence } from "@cocalc/frontend/markdown/util"; + +export type CellDirection = "above" | "below" | "around"; +export type CellCount = "none" | number | "all above" | "all below" | "all"; + +export interface CellContentOptions { + actions: NotebookFrameActions | undefined; + id: string; + direction: CellDirection; + cellCount: CellCount; + cellTypes?: "all" | "code" | "markdown"; + lang?: string; + aboveCount?: number; // For "around" direction + belowCount?: number; // For "around" direction +} + +export interface CellContextContent { + before?: string; + after?: string; +} + +/** + * Get content from nonempty cells in specified direction from a given cell + */ +export function getNonemptyCellContents({ + actions, + id, + direction, + cellCount, + cellTypes = "code", + lang, + aboveCount = 2, + belowCount = 2, +}: CellContentOptions): CellContextContent { + if (actions == null) return {}; + if (cellCount === "none" && direction !== "around") return {}; + + const jupyterActionsStore = actions?.jupyter_actions.store; + + if (direction === "around") { + const result: CellContextContent = {}; + + if (aboveCount > 0) { + const aboveContent = getDirectionalContent({ + actions, + id, + jupyterActionsStore, + cellTypes, + lang, + direction: "above", + count: aboveCount, + includeCurrentCell: true, + }); + if (aboveContent) result.before = aboveContent; + } + + if (belowCount > 0) { + const belowContent = getDirectionalContent({ + actions, + id, + jupyterActionsStore, + cellTypes, + lang, + direction: "below", + count: belowCount, + }); + if (belowContent) result.after = belowContent; + } + + return result; + } + + // Handle single direction - return in appropriate property + const count = + typeof cellCount === "number" + ? cellCount + : cellCount === "all above" || + cellCount === "all below" || + cellCount === "all" + ? 100 + : 0; + + const content = getDirectionalContent({ + actions, + id, + jupyterActionsStore, + cellTypes, + lang, + direction, + count, + }); + + if (!content) return {}; + + return direction === "above" ? { before: content } : { after: content }; +} + +function getDirectionalContent({ + actions, + id, + jupyterActionsStore, + cellTypes, + lang, + direction, + count, + includeCurrentCell = false, +}: { + actions: NotebookFrameActions; + id: string; + jupyterActionsStore: any; + cellTypes: "all" | "code" | "markdown"; + lang?: string; + direction: "above" | "below"; + count: number; + includeCurrentCell?: boolean; +}): string { + const cells: string[] = []; + let length = 0; + let delta = direction === "above" ? (includeCurrentCell ? 0 : -1) : 1; + let remainingCount = count; + + while (remainingCount > 0) { + const cellId = jupyterActionsStore.get_cell_id(delta, id); + if (!cellId) break; + + const cell = actions.get_cell_by_id(cellId); + if (!cell) break; + + const code = actions.get_cell_input(cellId)?.trim(); + const cellType = cell.get("cell_type", "code"); + + if (code && (cellTypes === "all" || cellType === cellTypes)) { + length += code.length; + if (length > CUTOFF) break; + + const delim = backtickSequence(code); + const formattedCode = + cellTypes === "all" && cellType === "code" + ? `${delim}${lang}\n${code}\n${delim}` + : code; + + if (direction === "above") { + cells.unshift(formattedCode); + } else { + cells.push(formattedCode); + } + + remainingCount--; + } + + delta += direction === "above" ? -1 : 1; + } + + return cells.join("\n\n"); +}