mirror of
https://github.com/SerTimBerrners-Lee/talkis.git
synced 2026-07-09 17:29:15 +00:00
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:
parent
2296e9bda8
commit
b3d353d7e1
6 changed files with 278 additions and 53 deletions
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue