diff --git a/src/components/LocalLlmModels.tsx b/src/components/LocalLlmModels.tsx index b1f9f92..a2707b4 100644 --- a/src/components/LocalLlmModels.tsx +++ b/src/components/LocalLlmModels.tsx @@ -61,6 +61,7 @@ export function LocalLlmModels({ const [busy, setBusy] = useState(null); const [deleting, setDeleting] = useState(null); const [error, setError] = useState(null); + const [expandedId, setExpandedId] = useState(null); const refresh = async (): Promise => { try { @@ -170,7 +171,9 @@ export function LocalLlmModels({ setError(null); try { const baseUrl = await invoke("start_local_llm", { modelId: model.id }); - update({ llmEndpoint: baseUrl, llmModel: model.id }); + // Mark this as the BUNDLED runtime so summary can auto-(re)start the sidecar + // after an app restart (the process dies but the endpoint stays persisted). + update({ llmEndpoint: baseUrl, llmModel: model.id, llmLocalModelId: model.id }); await refresh(); } catch (e) { setError(e instanceof Error ? e.message : String(e)); @@ -239,6 +242,9 @@ export function LocalLlmModels({ const isBusy = busy === model.id; const isDeleting = deleting === model.id; const isActive = activeModelId === model.id && usingLocalEndpoint; + // Collapsed by default like the recognition cards; force-open while a + // download runs so its progress bar stays visible. + const open = expandedId === model.id || isDownloading; const statusLabel = isDeleting ? t("localLlm.status.deleting") @@ -263,7 +269,11 @@ export function LocalLlmModels({ className="card" style={{ padding: 0, overflow: "hidden", background: "var(--surface)" }} > -
+
- + + {open && (
+ )} ); })} diff --git a/src/components/SummaryModal.tsx b/src/components/SummaryModal.tsx index 49de61c..e8a4509 100644 --- a/src/components/SummaryModal.tsx +++ b/src/components/SummaryModal.tsx @@ -1,6 +1,8 @@ import { useEffect, useMemo, useState } from "react"; +import { listen } from "@tauri-apps/api/event"; import { Check, Clipboard, Pencil, Trash2 } from "lucide-react"; +import { SETTINGS_UPDATED_EVENT } from "../lib/hotkeyEvents"; import { getSettings, listPromptsByKind, @@ -68,6 +70,19 @@ export function SummaryModal({ }; }, []); + // Live-refresh when prompts/settings change elsewhere (e.g. a new summary prompt + // is created in the «Стиль и промпты» tab) so the dropdown updates immediately. + useEffect(() => { + const unlistenPromise = listen(SETTINGS_UPDATED_EVENT, () => { + getSettings({ reload: true }) + .then(setSettings) + .catch(() => {}); + }); + return () => { + void unlistenPromise.then((dispose) => dispose()); + }; + }, []); + const prompts = settings ? listPromptsByKind(settings, "summary") : []; const selected = prompts.find((p) => p.id === promptId) ?? prompts[0]; const backend = settings ? resolveSummaryBackend(settings) : null; diff --git a/src/lib/i18n/dict/settingsModels.ts b/src/lib/i18n/dict/settingsModels.ts index 21979fb..321ac61 100644 --- a/src/lib/i18n/dict/settingsModels.ts +++ b/src/lib/i18n/dict/settingsModels.ts @@ -25,6 +25,8 @@ export const settingsModels = { en: "Choose a summary prompt. It is offered by default on the transcription result screen.", }, "models.prompt.create": { ru: "Создать промпт", en: "Create prompt" }, + "models.prompt.improve": { ru: "Улучшить промпт", en: "Improve prompt" }, + "models.prompt.improving": { ru: "Улучшаю…", en: "Improving…" }, // ── Text (summary) model card ── "models.textModel.title": { ru: "Текстовая модель", en: "Text model" }, diff --git a/src/lib/store.ts b/src/lib/store.ts index 51bdb87..d9fffc6 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -182,6 +182,11 @@ export interface AppSettings { whisperEndpoint: string; /** Custom LLM endpoint URL (leave empty for OpenAI) */ llmEndpoint: string; + /** When non-empty, llmEndpoint points at the BUNDLED local runtime serving this + * managed GGUF model id. Lets summary (re)start the sidecar before a request — + * the process dies on app restart while the endpoint stays persisted. Empty for + * a user's own local server (e.g. Ollama) or any non-local endpoint. */ + llmLocalModelId: string; /** If true, user provides their own API key. If false, uses subscription */ useOwnKey: boolean; /** Device auth token for Talkis Cloud */ @@ -505,6 +510,7 @@ const DEFAULT_SETTINGS: AppSettings = { micId: "", whisperEndpoint: "", llmEndpoint: "", + llmLocalModelId: "", useOwnKey: true, deviceToken: "", fileSpeakerDiarization: false, @@ -765,6 +771,8 @@ function normalizeSavedSettings(saved: unknown): Partial { typeof raw.whisperEndpoint === "string" ? raw.whisperEndpoint : undefined, llmEndpoint: typeof raw.llmEndpoint === "string" ? raw.llmEndpoint : undefined, + llmLocalModelId: + typeof raw.llmLocalModelId === "string" ? raw.llmLocalModelId : undefined, useOwnKey: typeof raw.useOwnKey === "boolean" ? raw.useOwnKey : undefined, deviceToken: typeof raw.deviceToken === "string" ? raw.deviceToken : undefined, diff --git a/src/lib/summarize.ts b/src/lib/summarize.ts index 80b38dc..c394d67 100644 --- a/src/lib/summarize.ts +++ b/src/lib/summarize.ts @@ -136,6 +136,38 @@ function llmRunner(endpoint: string, model: string, apiKey: string): RunOnce { }; } +/** + * Runner for the BUNDLED local runtime. The sidecar process dies when the app + * is closed, but `llmEndpoint` stays persisted — so a summary after restart hits + * a dead port ("error sending request"). We (re)start the runtime first via + * `start_local_llm` (idempotent: returns immediately with the live base URL if + * already running) and use whatever port it reports, since it can change. + */ +function localLlmRunner( + modelId: string, + fallbackEndpoint: string, + model: string, + apiKey: string, +): RunOnce { + let baseUrl: string | null = null; + return async ({ text, prompt, temperature }) => { + if (!baseUrl) { + baseUrl = await invoke("start_local_llm", { modelId }); + } + const response = await invoke("process_text", { + req: { + text, + prompt, + temperature: temperature ?? null, + endpoint: baseUrl || fallbackEndpoint || null, + model: model || null, + api_key: apiKey || null, + }, + }); + return response.result; + }; +} + /** * Pick the summary backend from settings: Talkis Cloud subscription, a custom * OpenAI-compatible endpoint, or a local runtime (127.0.0.1). Returns null when @@ -153,13 +185,16 @@ export function resolveSummaryBackend( const model = settings.llmModel?.trim() ?? ""; const apiKey = (settings.llmApiKey?.trim() || settings.apiKey?.trim()) ?? ""; const isLocal = /127\.0\.0\.1|localhost/i.test(endpoint); + // Only the BUNDLED runtime can be auto-(re)started; a user's own local server + // (Ollama/LM Studio) has no marker, so we just use its endpoint as-is. + const localModelId = settings.llmLocalModelId?.trim() ?? ""; if (endpoint) { - return { - kind: isLocal ? "local" : "custom", - label: endpoint, - run: llmRunner(endpoint, model, apiKey), - }; + const run = + isLocal && localModelId + ? localLlmRunner(localModelId, endpoint, model, apiKey) + : llmRunner(endpoint, model, apiKey); + return { kind: isLocal ? "local" : "custom", label: endpoint, run }; } // Own OpenAI key without a custom endpoint. @@ -180,6 +215,40 @@ export function isSummaryAvailable(settings: AppSettings): boolean { return resolveSummaryBackend(settings) !== null; } +const IMPROVE_PROMPT_INSTRUCTION = + "Ты — опытный prompt-инженер. Ниже дан черновик инструкции (промпта), которую " + + "пользователь применяет к расшифровке разговора или текста, чтобы получить саммари " + + "или иную обработку. Улучши эту инструкцию: сделай её чёткой, конкретной и " + + "однозначной, сохрани исходный смысл и язык, добавь полезную структуру, если уместно. " + + "НЕ выполняй инструкцию и не обрабатывай никакой текст — верни ТОЛЬКО улучшенный текст " + + "промпта, без пояснений, кавычек и преамбул."; + +/** + * Rewrite a draft prompt into a cleaner instruction using whichever text backend + * is configured (cloud / local runtime / custom endpoint). Used by the + * «Улучшить промпт» button in the prompt library. Requires a backend (the button + * is only shown when {@link isSummaryAvailable} is true). + */ +export async function improvePromptText( + settings: AppSettings, + draft: string, +): Promise { + const trimmed = draft.trim(); + if (!trimmed) { + throw new Error(tn("summarize.errEmptyPrompt")); + } + const backend = resolveSummaryBackend(settings); + if (!backend) { + throw new Error(tn("summarize.errNoModel")); + } + const improved = await backend.run({ + text: trimmed, + prompt: IMPROVE_PROMPT_INSTRUCTION, + temperature: 0.4, + }); + return improved.trim(); +} + async function runMapReduce( text: string, instruction: string, diff --git a/src/windows/settings/tabs/SettingsTabs.tsx b/src/windows/settings/tabs/SettingsTabs.tsx index e96c3bb..31ab7a6 100644 --- a/src/windows/settings/tabs/SettingsTabs.tsx +++ b/src/windows/settings/tabs/SettingsTabs.tsx @@ -13,6 +13,7 @@ import { Download, Gauge, HardDrive, + Loader2, LogOut, LucideIcon, MessageSquare, @@ -42,6 +43,7 @@ import { CloudProfile, fetchCloudProfile, getAuthLoginUrl, cloudLogout, handleAu import { logInfo } from "../../../lib/logger"; import { TRANSCRIPTION_STYLE_OPTIONS } from "../../../lib/transcriptionPrompts"; +import { isSummaryAvailable, improvePromptText } from "../../../lib/summarize"; import { LocalLlmModels } from "../../../components/LocalLlmModels"; import { SETTINGS_UPDATED_EVENT } from "../../../lib/hotkeyEvents"; import { useI18n, type MsgKey } from "../../../lib/i18n"; @@ -489,12 +491,56 @@ interface OptionCardProps { active?: boolean; icon: React.ReactNode; title: string; - description: string; + description: React.ReactNode; badge?: string; onClick?: () => void; disabled?: boolean; } +const CARD_TEXT_PREVIEW_LIMIT = 250; + +/** + * Long card text (e.g. a prompt body) shown truncated with a Раскрыть/Скрыть + * toggle — the same pattern as the history table. The toggle stops propagation so + * it doesn't also trigger the card's own onClick (which selects the prompt). + */ +function CollapsibleCardText({ text }: { text: string }) { + const { t } = useI18n(); + const [expanded, setExpanded] = useState(false); + const tooLong = text.length > CARD_TEXT_PREVIEW_LIMIT; + const visible = + tooLong && !expanded + ? `${text.slice(0, CARD_TEXT_PREVIEW_LIMIT).trimEnd()}...` + : text; + return ( +
+ {visible} + {tooLong && ( + + )} +
+ ); +} + function OptionCard({ active = false, icon, title, description, badge, onClick, disabled = false }: OptionCardProps) { return (
(null); const [saving, setSaving] = useState(false); + const [improving, setImproving] = useState(false); const [busyId, setBusyId] = useState(null); const [error, setError] = useState(null); + const promptFieldRef = useRef(null); + + // Auto-grow the prompt textarea to fit its content up to a cap, then scroll. + const autoSizePromptField = (el: HTMLTextAreaElement | null): void => { + if (!el) return; + const MAX_HEIGHT = 440; + el.style.height = "auto"; + el.style.height = `${Math.min(el.scrollHeight, MAX_HEIGHT)}px`; + el.style.overflowY = el.scrollHeight > MAX_HEIGHT ? "auto" : "hidden"; + }; + useEffect(() => { + autoSizePromptField(promptFieldRef.current); + }, [editor?.id, editor?.prompt]); + + const canImprove = isSummaryAvailable(settings); + const handleImprove = async (): Promise => { + if (!editor || !editor.prompt.trim() || improving) return; + setImproving(true); + setError(null); + try { + const improved = await improvePromptText(settings, editor.prompt); + setEditor((cur) => (cur ? { ...cur, prompt: improved } : cur)); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setImproving(false); + } + }; const summaryPrompts = listPromptsByKind(settings, "summary"); const defaultId = settings.defaultSummaryPromptId; @@ -806,13 +881,47 @@ function PromptLibrary({ />
-
{t("models.prompt.promptLabel")}
+
+
{t("models.prompt.promptLabel")}
+ {canImprove && ( + + )} +