diff --git a/src/packages/frontend/account/useLanguageModelSetting.tsx b/src/packages/frontend/account/useLanguageModelSetting.tsx index e5e74f8254f..c96a83b4ade 100644 --- a/src/packages/frontend/account/useLanguageModelSetting.tsx +++ b/src/packages/frontend/account/useLanguageModelSetting.tsx @@ -1,4 +1,5 @@ import { redux, useMemo, useTypedRedux } from "@cocalc/frontend/app-framework"; +import { EnabledLLMs } from "@cocalc/frontend/project/context"; import { LanguageModel, USER_SELECTABLE_LANGUAGE_MODELS, @@ -6,20 +7,17 @@ import { getValidLanguageModelName, isOllamaLLM, } from "@cocalc/util/db-schema/llm"; -import { useProjectContext } from "../project/context"; export const SETTINGS_LANGUAGE_MODEL_KEY = "language_model"; -// ATTN: requires the project context -export function useLanguageModelSetting(): [ - LanguageModel | string, - (llm: LanguageModel | string) => void, -] { +// ATTN: it is tempting to use the `useProjectContext` hook here, but it is not possible +// The "AI Formula" dialog is outside the project context (unfortunately) +export function useLanguageModelSetting( + enabledLLMs: EnabledLLMs, +): [LanguageModel | string, (llm: LanguageModel | string) => void] { const other_settings = useTypedRedux("account", "other_settings"); const ollama = useTypedRedux("customize", "ollama"); - const { enabledLLMs } = useProjectContext(); - const llm = useMemo(() => { return getValidLanguageModelName( other_settings?.get("language_model"), diff --git a/src/packages/frontend/codemirror/extensions/ai-formula.tsx b/src/packages/frontend/codemirror/extensions/ai-formula.tsx index 9f69441d060..db47b38238b 100644 --- a/src/packages/frontend/codemirror/extensions/ai-formula.tsx +++ b/src/packages/frontend/codemirror/extensions/ai-formula.tsx @@ -47,7 +47,9 @@ interface Props extends Opts { } function AiGenFormula({ mode, text = "", project_id, cb }: Props) { - const [model, setModel] = useLanguageModelSetting(); + const projectsStore = redux.getStore("projects"); + const enabledLLMs = projectsStore.whichLLMareEnabled(project_id); + const [model, setModel] = useLanguageModelSetting(enabledLLMs); const [input, setInput] = useState(text); const [formula, setFormula] = useState(""); const [generating, setGenerating] = useState(false); diff --git a/src/packages/frontend/frame-editors/llm/help-me-fix.tsx b/src/packages/frontend/frame-editors/llm/help-me-fix.tsx index 4d5ed71a59d..828c87bbfaf 100644 --- a/src/packages/frontend/frame-editors/llm/help-me-fix.tsx +++ b/src/packages/frontend/frame-editors/llm/help-me-fix.tsx @@ -51,7 +51,9 @@ export default function HelpMeFix({ const { redux, project_id, path } = useFrameContext(); const [gettingHelp, setGettingHelp] = useState(false); const [errorGettingHelp, setErrorGettingHelp] = useState(""); - const [model, setModel] = useLanguageModelSetting(); + const projectsStore = redux.getStore("projects"); + const enabledLLMs = projectsStore.whichLLMareEnabled(project_id); + const [model, setModel] = useLanguageModelSetting(enabledLLMs); if ( redux == null || !(redux.getStore("projects") as ProjectsStore).hasLanguageModelEnabled( diff --git a/src/packages/frontend/frame-editors/llm/model-switch.tsx b/src/packages/frontend/frame-editors/llm/model-switch.tsx index 85d9b804e9d..079d6980237 100644 --- a/src/packages/frontend/frame-editors/llm/model-switch.tsx +++ b/src/packages/frontend/frame-editors/llm/model-switch.tsx @@ -1,6 +1,8 @@ -import { Radio, Tooltip } from "antd"; +import type { SelectProps } from "antd"; +import { Select } from "antd"; import { CSS, redux, useTypedRedux } from "@cocalc/frontend/app-framework"; +import { LanguageModelVendorAvatar } from "@cocalc/frontend/components/language-model-icon"; import { DEFAULT_MODEL, LLM_USERNAMES, @@ -12,6 +14,7 @@ import { model2service, toOllamaModel, } from "@cocalc/util/db-schema/llm"; +import type { OllamaPublic } from "@cocalc/util/types/llm"; export { DEFAULT_MODEL }; export type { LanguageModel }; @@ -55,87 +58,106 @@ export default function ModelSwitch({ ); const ollama = useTypedRedux("customize", "ollama"); - function renderLLMButton(btnModel: LanguageModel, title: string) { + function getPrice(btnModel): string { + return isFreeModel(btnModel) ? "free" : "NOT free"; + } + + function makeLLMOption( + ret: NonNullable, + btnModel: LanguageModel, + title: string, + ) { if (!USER_SELECTABLE_LANGUAGE_MODELS.includes(btnModel)) return; - const prefix = isFreeModel(btnModel) ? "FREE" : "NOT FREE"; - return ( - - - {modelToName(btnModel)} - {btnModel === model - ? !isFreeModel(btnModel) - ? " (not free)" - : " (free)" - : undefined} - - - ); + + const display = modelToName(btnModel); + ret.push({ + value: btnModel, + display, + label: ( + <> + {" "} + {display} ({getPrice(btnModel)}): {title} + + ), + }); } - function renderOpenAI() { + function appendOpenAI(ret: NonNullable) { if (!showOpenAI) return null; - return ( - <> - {renderLLMButton( - "gpt-3.5-turbo", - "OpenAI's fastest model, great for most everyday tasks (4k token context)", - )} - {renderLLMButton( - "gpt-3.5-turbo-16k", - `Same as ${modelToName( - "gpt-3.5-turbo", - )} but with much larger context size (16k token context)`, - )} - {renderLLMButton( - "gpt-4", - "OpenAI's most capable model, great for tasks that require creativity and advanced reasoning (8k token context)", - )} - + + makeLLMOption( + ret, + "gpt-3.5-turbo", + "OpenAI's fastest model, great for most everyday tasks (4k token context)", + ); + makeLLMOption( + ret, + "gpt-3.5-turbo-16k", + `Same as ${modelToName( + "gpt-3.5-turbo", + )} but with much larger context size (16k token context)`, + ); + makeLLMOption( + ret, + "gpt-4", + "OpenAI's most capable model, great for tasks that require creativity and advanced reasoning (8k token context)", ); } - function renderGoogle() { + function appendGoogle(ret: NonNullable) { if (!showGoogle) return null; return ( <> - {renderLLMButton( + {makeLLMOption( + ret, GOOGLE_GEMINI, - `Google's Gemini Pro Generative AI model ('${GOOGLE_GEMINI}', 30k token context)`, + `Google's Gemini Pro Generative AI model (30k token context)`, )} ); } - function renderOllama() { + function appendOllama(ret: NonNullable) { if (!showOllama || !ollama) return null; - return Object.entries(ollama.toJS()).map(([key, config]) => { - const { display } = config; - return ( - - {display} - - ); - }); + for (const [key, config] of Object.entries(ollama.toJS())) { + const { display, desc } = config; + const ollamaModel = toOllamaModel(key); + ret.push({ + value: ollamaModel, + display, + label: ( + <> + {" "} + {display} ({getPrice(ollamaModel)}):{" "} + {desc ?? "Ollama"} + + ), + }); + } + } + + function getOptions(): SelectProps["options"] { + const ret: NonNullable = []; + appendOpenAI(ret); + appendGoogle(ret); + appendOllama(ret); + return ret; } - // all models selectable here must be in util/db-schema/openai::USER_SELECTABLE_LANGUAGE_MODELS + // all models selectable here must be in util/db-schema/openai::USER_SELECTABLE_LANGUAGE_MODELS + the custom ones from the ollama configuration return ( - { - setModel(value); - }} - > - {renderOpenAI()} - {renderGoogle()} - {renderOllama()} - + onChange={setModel} + style={{ width: 300 }} + optionLabelProp={"display"} + popupMatchSelectWidth={false} + options={getOptions()} + /> ); } diff --git a/src/packages/frontend/frame-editors/llm/title-bar-button.tsx b/src/packages/frontend/frame-editors/llm/title-bar-button.tsx index 638a81d3588..47df352ed9e 100644 --- a/src/packages/frontend/frame-editors/llm/title-bar-button.tsx +++ b/src/packages/frontend/frame-editors/llm/title-bar-button.tsx @@ -11,7 +11,9 @@ to do the work. import { Alert, Button, Input, Popover, Radio, Space, Tooltip } from "antd"; import { useEffect, useMemo, useRef, useState } from "react"; + import { useLanguageModelSetting } from "@cocalc/frontend/account/useLanguageModelSetting"; +import { redux } from "@cocalc/frontend/app-framework"; import { Icon, IconName, @@ -156,7 +158,10 @@ export default function LanguageModelTitleBarButton({ const scopeRef = useRef(null); const contextRef = useRef(null); const submitRef = useRef(null); - const [model, setModel] = useLanguageModelSetting(); + + const projectsStore = redux.getStore("projects"); + const enabledLLMs = projectsStore.whichLLMareEnabled(project_id); + const [model, setModel] = useLanguageModelSetting(enabledLLMs); useEffect(() => { if (showDialog) { diff --git a/src/packages/frontend/jupyter/chatgpt/explain.tsx b/src/packages/frontend/jupyter/chatgpt/explain.tsx index f53f8a6563a..4e5c5bfb69c 100644 --- a/src/packages/frontend/jupyter/chatgpt/explain.tsx +++ b/src/packages/frontend/jupyter/chatgpt/explain.tsx @@ -6,6 +6,7 @@ import { Alert, Button } from "antd"; import { CSSProperties, useState } from "react"; import { useLanguageModelSetting } from "@cocalc/frontend/account/useLanguageModelSetting"; +import { redux } from "@cocalc/frontend/app-framework"; import getChatActions from "@cocalc/frontend/chat/get-actions"; import AIAvatar from "@cocalc/frontend/components/ai-avatar"; import { Icon } from "@cocalc/frontend/components/icon"; @@ -31,7 +32,9 @@ export default function ChatGPTExplain({ actions, id, style }: Props) { const { project_id, path } = useFrameContext(); const [gettingExplanation, setGettingExplanation] = useState(false); const [error, setError] = useState(""); - const [model, setModel] = useLanguageModelSetting(); + const projectsStore = redux.getStore("projects"); + const enabledLLMs = projectsStore.whichLLMareEnabled(project_id); + const [model, setModel] = useLanguageModelSetting(enabledLLMs); if ( actions == null || diff --git a/src/packages/frontend/jupyter/insert-cell/ai-cell-generator.tsx b/src/packages/frontend/jupyter/insert-cell/ai-cell-generator.tsx index 15ac5ef97b6..1b2df5b42d8 100644 --- a/src/packages/frontend/jupyter/insert-cell/ai-cell-generator.tsx +++ b/src/packages/frontend/jupyter/insert-cell/ai-cell-generator.tsx @@ -4,7 +4,7 @@ import React, { useMemo, useState } from "react"; import { useLanguageModelSetting } from "@cocalc/frontend/account/useLanguageModelSetting"; import { alert_message } from "@cocalc/frontend/alerts"; -import { useFrameContext } from "@cocalc/frontend/app-framework"; +import { redux, useFrameContext } from "@cocalc/frontend/app-framework"; import { Paragraph } from "@cocalc/frontend/components"; import { Icon } from "@cocalc/frontend/components/icon"; import { LanguageModelVendorAvatar } from "@cocalc/frontend/components/language-model-icon"; @@ -44,9 +44,12 @@ export default function AIGenerateCodeCell({ setShowChatGPT, showChatGPT, }: AIGenerateCodeCellProps) { - const [model, setModel] = useLanguageModelSetting(); - const [querying, setQuerying] = useState(false); const { project_id, path } = useFrameContext(); + + const projectsStore = redux.getStore("projects"); + const enabledLLMs = projectsStore.whichLLMareEnabled(project_id); + const [model, setModel] = useLanguageModelSetting(enabledLLMs); + const [querying, setQuerying] = useState(false); const [prompt, setPrompt] = useState(""); const input = useMemo(() => { if (!showChatGPT) return ""; diff --git a/src/packages/frontend/project/context.tsx b/src/packages/frontend/project/context.tsx index 2afed6d0813..727dd6fc374 100644 --- a/src/packages/frontend/project/context.tsx +++ b/src/packages/frontend/project/context.tsx @@ -26,6 +26,11 @@ import { useProjectStatus } from "./page/project-status-hook"; import { useProjectHasInternetAccess } from "./settings/has-internet-access-hook"; import { Project } from "./settings/types"; +export interface EnabledLLMs { + openai: boolean; + google: boolean; + ollama: boolean; +} export interface ProjectContextState { actions?: ProjectActions; active_project_tab?: string; @@ -39,11 +44,7 @@ export interface ProjectContextState { flipTabs: [number, React.Dispatch>]; onCoCalcCom: boolean; onCoCalcDocker: boolean; - enabledLLMs: { - openai: boolean; - google: boolean; - ollama: boolean; - }; + enabledLLMs: EnabledLLMs; } export const ProjectContext: Context = diff --git a/src/packages/frontend/project/page/home-page/ai-generate-jupyter.tsx b/src/packages/frontend/project/page/home-page/ai-generate-jupyter.tsx index af1a2bd4134..f31733f1e5e 100644 --- a/src/packages/frontend/project/page/home-page/ai-generate-jupyter.tsx +++ b/src/packages/frontend/project/page/home-page/ai-generate-jupyter.tsx @@ -86,7 +86,9 @@ export default function AIGenerateJupyterNotebook({ onSuccess, project_id, }: Props) { - const [model, setModel] = useLanguageModelSetting(); + const projectsStore = redux.getStore("projects"); + const enabledLLMs = projectsStore.whichLLMareEnabled(project_id); + const [model, setModel] = useLanguageModelSetting(enabledLLMs); const [kernelSpecs, setKernelSpecs] = useState( null, ); diff --git a/src/packages/hub/webapp-configuration.ts b/src/packages/hub/webapp-configuration.ts index 8dd725d6461..ba72d40177b 100644 --- a/src/packages/hub/webapp-configuration.ts +++ b/src/packages/hub/webapp-configuration.ts @@ -191,6 +191,7 @@ export class WebappConfiguration { model, display: cocalc.display ?? `Ollama ${model}`, icon: cocalc.icon, // fallback is the Ollama icon, frontend does that + desc: cocalc.desc ?? "", }; } return public_ollama; diff --git a/src/packages/util/db-schema/site-settings-extras.ts b/src/packages/util/db-schema/site-settings-extras.ts index 4557aa0a64a..7092a46a485 100644 --- a/src/packages/util/db-schema/site-settings-extras.ts +++ b/src/packages/util/db-schema/site-settings-extras.ts @@ -34,6 +34,7 @@ import { } from "./site-defaults"; import { expire_time, is_valid_email_address } from "@cocalc/util/misc"; +import { isEmpty } from "lodash"; export const pii_retention_parse = (retention: string): number | false => { if (retention == "never" || retention == null) return false; @@ -85,7 +86,7 @@ const jupyter_api_enabled = (conf: SiteSettings) => to_bool(conf.jupyter_api_enabled); function ollama_valid(value: string): boolean { - if (!parsableJson(value)) { + if (isEmpty(value) || !parsableJson(value)) { return false; } const obj = from_json(value); @@ -111,6 +112,9 @@ function ollama_valid(value: string): boolean { if (c.display && typeof c.display !== "string") { return false; } + if (c.desc && typeof c.desc !== "string") { + return false; + } if (c.enabled && typeof c.enabled !== "boolean") { return false; } @@ -120,8 +124,13 @@ function ollama_valid(value: string): boolean { } function ollama_display(value: string): string { + const structure = + "Must be {[key : string] : {model: string, baseUrL: string, cocalc?: {display?: string, desc?: string, ...}, ...}"; + if (isEmpty(value)) { + return `Empty. ${structure}`; + } if (!parsableJson(value)) { - return "Ollama JSON not parseable. Must be {[key : string] : {model: string, baseUrL: string, cocalc: {display: string, ...}, ...}"; + return `Ollama JSON not parseable. ${structure}`; } const obj = from_json(value); if (typeof obj !== "object") { @@ -142,11 +151,14 @@ function ollama_display(value: string): string { const c = val.cocalc; if (c != null) { if (typeof c !== "object") { - return `Ollama config ${key} cocalc field must be an object`; + return `Ollama config ${key} cocalc field must be an object: {display?: string, desc?: string, enabled?: boolean, ...}`; } if (c.display && typeof c.display !== "string") { return `Ollama config ${key} cocalc.display field must be a string`; } + if (c.desc && typeof c.desc !== "string") { + return `Ollama config ${key} cocalc.desc field must be a (markdown) string`; + } if (c.enabled && typeof c.enabled !== "boolean") { return `Ollama config ${key} cocalc.enabled field must be a boolean`; } diff --git a/src/packages/util/types/llm.ts b/src/packages/util/types/llm.ts index f9395d166d5..9cbd785746a 100644 --- a/src/packages/util/types/llm.ts +++ b/src/packages/util/types/llm.ts @@ -33,6 +33,7 @@ export interface ChatOptions { export interface OllamaPublic { model: string; - display: string; + display: string; // name of the model + desc?: string; // additional description icon?: string; // fallback to OllamaAvatar }