feat(summary): improve-prompt button, fix local summary after restart, live prompt dropdown, collapsible prompt cards + accordion model cards

- Fix local summary hitting a dead port after app restart: the talkis-llm sidecar dies while llmEndpoint stays persisted. Lazily (re)start the bundled runtime before a local summary (new llmLocalModelId marker) and use the live port.
- Add Улучшить промпт button (shown when a text backend is configured); rewrites the draft via the model.
- SummaryModal listens to SETTINGS_UPDATED_EVENT so a freshly created prompt shows in the dropdown immediately.
- Long prompt text in cards collapses with Раскрыть/Скрыть like the history table.
- Prompt textarea auto-grows to content up to a cap, then scrolls.
- TextModelCard (API) and local LLM cards become collapsed-by-default accordions matching the recognition cards.
This commit is contained in:
Tim 2026-06-25 22:32:11 +03:00
parent 2296e9bda8
commit b3d353d7e1
6 changed files with 278 additions and 53 deletions

View file

@ -61,6 +61,7 @@ export function LocalLlmModels({
const [busy, setBusy] = useState<string | null>(null);
const [deleting, setDeleting] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const refresh = async (): Promise<void> => {
try {
@ -170,7 +171,9 @@ export function LocalLlmModels({
setError(null);
try {
const baseUrl = await invoke<string>("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)" }}
>
<div style={{ padding: "12px 14px", display: "flex", alignItems: "center", gap: 12 }}>
<button
type="button"
onClick={() => setExpandedId(expandedId === model.id ? null : model.id)}
style={{ width: "100%", border: "none", background: "transparent", padding: "12px 14px", display: "flex", alignItems: "center", gap: 12, cursor: "pointer", textAlign: "left", fontFamily: "var(--font-main)" }}
>
<div
style={{
width: 36,
@ -355,8 +365,9 @@ export function LocalLlmModels({
</div>
</div>
</div>
</div>
</button>
{open && (
<div
style={{
borderTop: "1px solid var(--border-subtle)",
@ -493,6 +504,7 @@ export function LocalLlmModels({
</div>
</div>
</div>
)}
</div>
);
})}

View file

@ -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;

View file

@ -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" },

View file

@ -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<AppSettings> {
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,

View file

@ -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<string>("start_local_llm", { modelId });
}
const response = await invoke<ProcessTextResponse>("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<string> {
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,

View file

@ -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 (
<div style={{ display: "grid", gap: 3 }}>
<span style={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{visible}</span>
{tooLong && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
setExpanded((v) => !v);
}}
style={{
justifySelf: "start",
padding: 0,
border: "none",
background: "transparent",
color: "var(--text-hi)",
fontSize: 13,
fontWeight: 600,
cursor: "pointer",
fontFamily: "var(--font-main)",
}}
>
{expanded ? t("mainTab.collapse") : t("mainTab.expand")}
</button>
)}
</div>
);
}
function OptionCard({ active = false, icon, title, description, badge, onClick, disabled = false }: OptionCardProps) {
return (
<div
@ -733,8 +779,37 @@ function PromptLibrary({
const { t } = useI18n();
const [editor, setEditor] = useState<PromptEditorState | null>(null);
const [saving, setSaving] = useState(false);
const [improving, setImproving] = useState(false);
const [busyId, setBusyId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const promptFieldRef = useRef<HTMLTextAreaElement>(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<void> => {
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({
/>
</div>
<div style={{ display: "grid", gap: 6 }}>
<div className="label">{t("models.prompt.promptLabel")}</div>
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 10 }}>
<div className="label">{t("models.prompt.promptLabel")}</div>
{canImprove && (
<button
type="button"
onClick={() => void handleImprove()}
disabled={improving || !editor.prompt.trim()}
style={{
display: "flex",
alignItems: "center",
gap: 6,
padding: "5px 10px",
borderRadius: 8,
border: "1px solid var(--border)",
background: "var(--control-bg)",
color: "var(--text-hi)",
fontSize: 12,
fontWeight: 600,
cursor: improving || !editor.prompt.trim() ? "default" : "pointer",
opacity: improving || !editor.prompt.trim() ? 0.6 : 1,
fontFamily: "var(--font-main)",
}}
>
{improving ? (
<Loader2 size={14} strokeWidth={2.2} style={{ animation: "spin 1s linear infinite" }} />
) : (
<Sparkles size={14} strokeWidth={2} />
)}
{improving ? t("models.prompt.improving") : t("models.prompt.improve")}
</button>
)}
</div>
<textarea
ref={promptFieldRef}
value={editor.prompt}
onChange={(e) => setEditor({ ...editor, prompt: e.target.value })}
onChange={(e) => {
setEditor({ ...editor, prompt: e.target.value });
autoSizePromptField(e.target);
}}
placeholder={t("models.prompt.promptPlaceholder")}
rows={6}
style={{ ...PROMPT_FIELD_STYLE, resize: "vertical", minHeight: 120 }}
style={{ ...PROMPT_FIELD_STYLE, resize: "none", minHeight: 120, overflowY: "hidden" }}
/>
</div>
{error && (
@ -879,7 +988,7 @@ function PromptLibrary({
active={active}
icon={<MessageSquare size={20} strokeWidth={active ? 2.4 : 1.8} />}
title={preset.name}
description={preset.prompt}
description={<CollapsibleCardText text={preset.prompt} />}
onClick={() => update({ defaultSummaryPromptId: preset.id })}
/>
);
@ -974,6 +1083,7 @@ function TextModelCard({
update: (patch: Partial<AppSettings>) => void;
}) {
const { t } = useI18n();
const [expanded, setExpanded] = useState(false);
// The model name has a built-in default ("gpt-4o-mini"), so only the endpoint
// or the text-model's own API key signal that the user actually set this up.
const configured =
@ -986,9 +1096,16 @@ function TextModelCard({
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
fontSize: 12,
};
// Editing a custom text model means it's no longer the bundled local runtime, so
// drop the marker that tells summary to auto-start the sidecar.
const editField = (patch: Partial<AppSettings>) => update({ ...patch, llmLocalModelId: "" });
return (
<div className="card" style={{ padding: 0, overflow: "hidden", background: "var(--surface)" }}>
<div style={{ padding: "12px 14px", display: "flex", alignItems: "center", gap: 12 }}>
<button
type="button"
onClick={() => setExpanded((v) => !v)}
style={{ width: "100%", border: "none", background: "transparent", padding: "12px 14px", display: "flex", alignItems: "center", gap: 12, cursor: "pointer", textAlign: "left", fontFamily: "var(--font-main)" }}
>
<div style={{ width: 36, height: 36, borderRadius: 999, background: "var(--icon-soft-bg)", display: "flex", alignItems: "center", justifyContent: "center", flexShrink: 0 }}>
<Sparkles size={18} strokeWidth={1.9} color="var(--text-hi)" />
</div>
@ -1003,46 +1120,48 @@ function TextModelCard({
{t("models.textModel.desc")}
</div>
</div>
</div>
</button>
<div style={{ borderTop: "1px solid var(--border-subtle)", padding: "12px 14px", display: "flex", flexDirection: "column", gap: 10 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div className="label" style={{ width: 76, flexShrink: 0 }}>{t("models.field.apiKey")}</div>
<input
type="password"
value={settings.llmApiKey}
onChange={(e) => update({ llmApiKey: e.target.value })}
className="input"
placeholder={t("models.textModel.apiKeyPlaceholder")}
spellCheck={false}
style={FIELD_STYLE}
/>
{expanded && (
<div style={{ borderTop: "1px solid var(--border-subtle)", padding: "12px 14px", display: "flex", flexDirection: "column", gap: 10 }}>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div className="label" style={{ width: 76, flexShrink: 0 }}>{t("models.field.apiKey")}</div>
<input
type="password"
value={settings.llmApiKey}
onChange={(e) => editField({ llmApiKey: e.target.value })}
className="input"
placeholder={t("models.textModel.apiKeyPlaceholder")}
spellCheck={false}
style={FIELD_STYLE}
/>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div className="label" style={{ width: 76, flexShrink: 0 }}>{t("models.field.model")}</div>
<input
type="text"
value={settings.llmModel}
onChange={(e) => editField({ llmModel: e.target.value })}
className="input"
placeholder="gpt-4o-mini"
spellCheck={false}
style={FIELD_STYLE}
/>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div className="label" style={{ width: 76, flexShrink: 0 }}>{t("models.field.host")}</div>
<input
type="url"
value={settings.llmEndpoint}
onChange={(e) => editField({ llmEndpoint: e.target.value })}
className="input"
placeholder="https://api.openai.com/v1 · http://127.0.0.1:2455/v1"
spellCheck={false}
style={FIELD_STYLE}
/>
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div className="label" style={{ width: 76, flexShrink: 0 }}>{t("models.field.model")}</div>
<input
type="text"
value={settings.llmModel}
onChange={(e) => update({ llmModel: e.target.value })}
className="input"
placeholder="gpt-4o-mini"
spellCheck={false}
style={FIELD_STYLE}
/>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 10 }}>
<div className="label" style={{ width: 76, flexShrink: 0 }}>{t("models.field.host")}</div>
<input
type="url"
value={settings.llmEndpoint}
onChange={(e) => update({ llmEndpoint: e.target.value })}
className="input"
placeholder="https://api.openai.com/v1 · http://127.0.0.1:2455/v1"
spellCheck={false}
style={FIELD_STYLE}
/>
</div>
</div>
)}
</div>
);
}