mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-29 10:21:34 +00:00
* Add Helm and GitOps deployment setup * fix: build better-sqlite3 from source in Docker (#1267) * fix: preserve runtime SSL settings (#1268) * fix: support forwarding from the memory SSH agent (#1269) * fix: support forwarding from the memory agent * style: format memory agent test * fix: prompt for encrypted SFTP key passphrases (#1270) * fix: prompt for SFTP key passphrases * style: format SSH key utility test * fix: include host context in automation notifications (#1271) * fix: include host context in automation notifications * style: format automation notification changes * fix: reserve sidebar height for host tags (#1272) * fix: keep host action rows stable at large font sizes (#1273) * fix: honor certificate setting during server probe (#1274) * fix: package standard Linux icon sizes (#1275) * fix: avoid duplicate Docker HTTPS listener (#1276) * Fix host status without metrics collection (#1277) * fix: allow eight-digit secure auth codes (#1263) Allow TOTP prompts to accept secure auth codes longer than six digits without blocking valid authentication attempts. Generated with Codebuff 🤖 Co-authored-by: Chetan <chetan.development@gmail.com> Co-authored-by: Codebuff <noreply@codebuff.com> * Harden Helm deployment defaults * Update Helm workflow action * Exclude Helm templates from Prettier * Fix browser RDP file drops (#1279) * Fix Proxmox guest credential usernames (#1280) * Add WSL local terminal option (#1281) * refactor: split the transfer engine into focused modules (#1282) * refactor: extract SFTP promisify helpers into sftp-promisify module * refactor: extract transfer timing and rate stats into transfer-stats module * refactor: extract transfer error classes and recovery checks into transfer-errors module * refactor: extract host/path utility helpers into transfer-host-utils module * refactor: extract SFTP directory tree helpers into transfer-sftp-dir module * refactor: extract segment copy job builder into transfer-segment-copy module * refactor: extract file scan and sample helpers into transfer-scan module * refactor: move throttled progress helper into transfer-stats module * style: format transfer modules * perf: optimize tmux monitor aggregation (#1283) * fix: reserve credential tag row height (#1284) * feat: edit AI provider model settings (#1285) * fix: clarify click-to-expand host setting (#1286) * fix: allow portable imports on remote databases (#1287) * fix: allow HTTPS to share the configured port (#1288) * fix: resolve synced jump hosts on the server (#1289) * fix: make terminal clipboard shortcuts layout independent (#1290) * fix: use compatible fetch dispatcher for Tailscale (#1291) * fix: add OIDC environment recovery override (#1292) * fix: coalesce rapid mobile terminal input (#1293) * fix: coalesce rapid mobile terminal input * fix: support clean xterm patch installs * fix: resolve synced remote desktop host IDs (#1295) * feat: make the SFTP file manager path bar editable (#1294) Co-authored-by: Maxime Bonillo <257463937+dropafterfree@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> * feat: add passkey sign in to the login screen * fix: remove rounded corners from the host list search bar * fix: stop image storage settings text wrapping to one word per line * fix: prevent malformed websocket messages from crashing the server * chore: increment version * fix: remove gaps between host rows in the sidebar list Keep sub-pixel row measurements and stop wiping the size cache on hover. * fix: Failed to connect through jump hosts (#1180) https://github.com/Termix-SSH/Support/issues/1180 * feat: Progress bar for file downloads in the file manager (#1158) https://github.com/Termix-SSH/Support/issues/1158 * feat: Allow setting Silent OIDC Login via ENV var (#1174) https://github.com/Termix-SSH/Support/issues/1174 * feat: `IdentityFile` to limit the number of attempts by agents (#1165) https://github.com/Termix-SSH/Support/issues/1165 * feat: Credentials clone (#1159) https://github.com/Termix-SSH/Support/issues/1159 * chore: update release notes * docs: move helm setup guide to the docs site * fix: type errors in FilteredAgent agent identity handling * fix: remove stale better-sqlite3 prebuilds so the source build is used * fix: actually build better-sqlite3 from source so arm64 docker images work * fix: credential edit pencil in host editor and add clone action to credential list * fix: clear editingHost so the credential pencil actually opens the editor * chore: run format and lint * fix: folder drag and drop upload failing in the file manager * chore: sync Crowdin translations for 2.7.1 --------- Co-authored-by: alex-ctms <alex-ctms@users.noreply.github.com> Co-authored-by: ZacharyZcR <zacharyzcr1984@gmail.com> Co-authored-by: Chetan Kumar <74929596+ckloop@users.noreply.github.com> Co-authored-by: Chetan <chetan.development@gmail.com> Co-authored-by: Codebuff <noreply@codebuff.com> Co-authored-by: ZacharyZcR <payasonorahc@protonmail.com> Co-authored-by: dropafterfree <maxime.bonillo@gmail.com> Co-authored-by: Maxime Bonillo <257463937+dropafterfree@users.noreply.github.com>
545 lines
16 KiB
TypeScript
545 lines
16 KiB
TypeScript
import { getErrorMessage } from "../../lib/error-message.js";
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { toast } from "sonner";
|
|
import { Loader2, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
|
|
import { Button } from "@/components/button";
|
|
import { Input } from "@/components/input";
|
|
import { Label } from "@/components/label";
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/select";
|
|
import {
|
|
createAiProvider,
|
|
deleteAiProvider,
|
|
getAiProviderModels,
|
|
probeAiModels,
|
|
updateAiProvider,
|
|
type AiProvider,
|
|
type AiProviderType,
|
|
} from "@/api/ai-api";
|
|
|
|
const PROVIDER_TYPES: Array<{
|
|
value: AiProviderType;
|
|
labelKey: string;
|
|
needsBaseUrl: boolean;
|
|
needsApiKey: boolean;
|
|
defaultBaseUrl?: string;
|
|
}> = [
|
|
{
|
|
value: "ollama",
|
|
labelKey: "ai.providerOllama",
|
|
needsBaseUrl: true,
|
|
needsApiKey: false,
|
|
defaultBaseUrl: "http://localhost:11434",
|
|
},
|
|
{
|
|
value: "anthropic",
|
|
labelKey: "ai.providerAnthropic",
|
|
needsBaseUrl: false,
|
|
needsApiKey: true,
|
|
},
|
|
{
|
|
value: "openai",
|
|
labelKey: "ai.providerOpenai",
|
|
needsBaseUrl: false,
|
|
needsApiKey: true,
|
|
},
|
|
{
|
|
value: "gemini",
|
|
labelKey: "ai.providerGemini",
|
|
needsBaseUrl: false,
|
|
needsApiKey: true,
|
|
},
|
|
{
|
|
value: "openai_compatible",
|
|
labelKey: "ai.providerOpenaiCompatible",
|
|
needsBaseUrl: true,
|
|
needsApiKey: false,
|
|
},
|
|
];
|
|
|
|
interface AiProviderSettingsProps {
|
|
providers: AiProvider[];
|
|
/** selectId names a provider that should become the active one. */
|
|
onChanged: (selectId?: number) => void;
|
|
onAdded?: () => void;
|
|
}
|
|
|
|
function AiProviderEditForm({
|
|
provider,
|
|
onSaved,
|
|
onCancel,
|
|
}: {
|
|
provider: AiProvider;
|
|
onSaved: () => void;
|
|
onCancel: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [label, setLabel] = useState(provider.label);
|
|
const [defaultModel, setDefaultModel] = useState(provider.defaultModel ?? "");
|
|
const [models, setModels] = useState<string[]>([]);
|
|
const [customModel, setCustomModel] = useState(false);
|
|
const [detecting, setDetecting] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
const detectModels = useCallback(async () => {
|
|
setDetecting(true);
|
|
try {
|
|
const detected = await getAiProviderModels(provider.id);
|
|
setModels(detected);
|
|
setCustomModel(!!defaultModel && !detected.includes(defaultModel));
|
|
} catch {
|
|
setModels([]);
|
|
setCustomModel(true);
|
|
} finally {
|
|
setDetecting(false);
|
|
}
|
|
}, [provider.id, defaultModel]);
|
|
|
|
useEffect(() => {
|
|
void detectModels();
|
|
// The initial model value belongs to this provider. Subsequent edits must
|
|
// not trigger a provider model-list request on every keystroke.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [provider.id]);
|
|
|
|
async function handleSave() {
|
|
if (!label.trim()) {
|
|
toast.error(t("ai.labelRequired"));
|
|
return;
|
|
}
|
|
|
|
setSaving(true);
|
|
try {
|
|
await updateAiProvider(provider.id, {
|
|
label: label.trim(),
|
|
defaultModel: defaultModel.trim() || null,
|
|
});
|
|
toast.success(t("ai.providerUpdated"));
|
|
onSaved();
|
|
} catch (error) {
|
|
toast.error(getErrorMessage(error, t("ai.providerSaveFailed")));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-3 rounded-none border border-border p-3">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor={`ai-provider-label-${provider.id}`}>
|
|
{t("ai.providerLabel")}
|
|
</Label>
|
|
<Input
|
|
id={`ai-provider-label-${provider.id}`}
|
|
className="rounded-none"
|
|
value={label}
|
|
onChange={(event) => setLabel(event.target.value)}
|
|
autoFocus
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center gap-2">
|
|
<Label
|
|
htmlFor={`ai-provider-model-${provider.id}`}
|
|
className="min-w-0 flex-1"
|
|
>
|
|
{t("ai.defaultModel")}
|
|
</Label>
|
|
<button
|
|
type="button"
|
|
className="flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground disabled:opacity-50"
|
|
onClick={() => void detectModels()}
|
|
disabled={detecting}
|
|
>
|
|
{detecting ? (
|
|
<Loader2 size={11} className="animate-spin" />
|
|
) : (
|
|
<RefreshCw size={11} />
|
|
)}
|
|
{t("ai.modelRefresh")}
|
|
</button>
|
|
</div>
|
|
|
|
{models.length > 0 && !customModel ? (
|
|
<Select
|
|
value={defaultModel || undefined}
|
|
onValueChange={(value) => {
|
|
if (value === "__custom__") {
|
|
setCustomModel(true);
|
|
setDefaultModel("");
|
|
return;
|
|
}
|
|
setDefaultModel(value);
|
|
}}
|
|
>
|
|
<SelectTrigger
|
|
id={`ai-provider-model-${provider.id}`}
|
|
className="rounded-none"
|
|
>
|
|
<SelectValue placeholder={t("ai.modelPlaceholder")} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{models.map((model) => (
|
|
<SelectItem key={model} value={model}>
|
|
{model}
|
|
</SelectItem>
|
|
))}
|
|
<SelectItem value="__custom__">{t("ai.modelCustom")}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
<Input
|
|
id={`ai-provider-model-${provider.id}`}
|
|
className="rounded-none"
|
|
value={defaultModel}
|
|
onChange={(event) => setDefaultModel(event.target.value)}
|
|
placeholder={t("ai.defaultModelPlaceholder")}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<Button size="sm" disabled={saving} onClick={() => void handleSave()}>
|
|
{saving && <Loader2 size={14} className="animate-spin" />}
|
|
{t("ai.save")}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={saving}
|
|
onClick={onCancel}
|
|
>
|
|
{t("ai.cancel")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function AiProviderSettings({
|
|
providers,
|
|
onChanged,
|
|
onAdded,
|
|
}: AiProviderSettingsProps) {
|
|
const { t } = useTranslation();
|
|
const [adding, setAdding] = useState(false);
|
|
const [editingId, setEditingId] = useState<number | null>(null);
|
|
const [saving, setSaving] = useState(false);
|
|
const [providerType, setProviderType] = useState<AiProviderType>("ollama");
|
|
const [label, setLabel] = useState("");
|
|
const [baseUrl, setBaseUrl] = useState("http://localhost:11434");
|
|
const [apiKey, setApiKey] = useState("");
|
|
const [defaultModel, setDefaultModel] = useState("");
|
|
|
|
const [models, setModels] = useState<string[]>([]);
|
|
const [detecting, setDetecting] = useState(false);
|
|
const [detectWarning, setDetectWarning] = useState<string | null>(null);
|
|
const [customModel, setCustomModel] = useState(false);
|
|
|
|
const spec = PROVIDER_TYPES.find((entry) => entry.value === providerType)!;
|
|
|
|
useEffect(() => {
|
|
setBaseUrl(spec.defaultBaseUrl ?? "");
|
|
setApiKey("");
|
|
setModels([]);
|
|
setDefaultModel("");
|
|
setCustomModel(false);
|
|
setDetectWarning(null);
|
|
}, [providerType, spec.defaultBaseUrl]);
|
|
|
|
/**
|
|
* Fetches the provider's own model list so nobody has to go and look model
|
|
* names up. Falls back to a curated list when the endpoint is unreachable,
|
|
* and a free-text field is always available for anything not listed.
|
|
*/
|
|
const detectModels = useCallback(async () => {
|
|
setDetecting(true);
|
|
setDetectWarning(null);
|
|
try {
|
|
const result = await probeAiModels({
|
|
providerType,
|
|
baseUrl: baseUrl.trim() || null,
|
|
apiKey: apiKey.trim() || null,
|
|
});
|
|
setModels(result.models);
|
|
if (result.source === "fallback") {
|
|
setDetectWarning(t("ai.modelDetectFailed"));
|
|
}
|
|
// Pick the first suggestion so the field is never left empty.
|
|
setDefaultModel((current) => current || result.models[0] || "");
|
|
} catch {
|
|
setDetectWarning(t("ai.modelDetectFailed"));
|
|
} finally {
|
|
setDetecting(false);
|
|
}
|
|
}, [providerType, baseUrl, apiKey, t]);
|
|
|
|
// Detect as soon as the provider has enough detail to be reachable.
|
|
useEffect(() => {
|
|
if (!adding) return;
|
|
const ready = spec.needsApiKey ? apiKey.trim().length > 0 : true;
|
|
if (!ready) return;
|
|
const timer = setTimeout(() => void detectModels(), 400);
|
|
return () => clearTimeout(timer);
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [adding, providerType, baseUrl, apiKey]);
|
|
|
|
async function handleAdd() {
|
|
if (!label.trim()) {
|
|
toast.error(t("ai.labelRequired"));
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
const created = await createAiProvider({
|
|
providerType,
|
|
label: label.trim(),
|
|
baseUrl: baseUrl.trim() || null,
|
|
apiKey: apiKey.trim() || null,
|
|
defaultModel: defaultModel.trim() || null,
|
|
});
|
|
setAdding(false);
|
|
setLabel("");
|
|
setApiKey("");
|
|
setDefaultModel("");
|
|
onChanged(created.id);
|
|
onAdded?.();
|
|
} catch (error) {
|
|
toast.error(getErrorMessage(error, t("ai.providerSaveFailed")));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function handleDelete(id: number) {
|
|
try {
|
|
await deleteAiProvider(id);
|
|
onChanged();
|
|
} catch (error) {
|
|
toast.error(getErrorMessage(error, t("ai.providerDeleteFailed")));
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{providers.map((provider) =>
|
|
editingId === provider.id ? (
|
|
<AiProviderEditForm
|
|
key={provider.id}
|
|
provider={provider}
|
|
onSaved={() => {
|
|
setEditingId(null);
|
|
onChanged(provider.id);
|
|
}}
|
|
onCancel={() => setEditingId(null)}
|
|
/>
|
|
) : (
|
|
<div
|
|
key={provider.id}
|
|
className="flex items-center justify-between gap-2 rounded-none border border-border bg-muted px-3 py-2"
|
|
>
|
|
<div className="min-w-0">
|
|
<div className="truncate text-sm font-medium">
|
|
{provider.label}
|
|
</div>
|
|
<div className="truncate text-xs text-muted-foreground">
|
|
{provider.providerType}
|
|
{provider.defaultModel ? ` · ${provider.defaultModel}` : ""}
|
|
{provider.baseUrl ? ` · ${provider.baseUrl}` : ""}
|
|
{provider.apiKeyPrefix ? ` · ${provider.apiKeyPrefix}…` : ""}
|
|
</div>
|
|
</div>
|
|
<div className="flex shrink-0 items-center">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => {
|
|
setAdding(false);
|
|
setEditingId(provider.id);
|
|
}}
|
|
aria-label={t("ai.editProvider")}
|
|
>
|
|
<Pencil size={14} />
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => handleDelete(provider.id)}
|
|
aria-label={t("ai.removeProvider")}
|
|
>
|
|
<Trash2 size={14} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
),
|
|
)}
|
|
|
|
{!adding && (
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={() => {
|
|
setEditingId(null);
|
|
setAdding(true);
|
|
}}
|
|
>
|
|
<Plus size={14} />
|
|
{t("ai.addProvider")}
|
|
</Button>
|
|
)}
|
|
|
|
{adding && (
|
|
<div className="space-y-3 rounded-none border border-border p-3">
|
|
<div className="space-y-1.5">
|
|
<Label>{t("ai.providerType")}</Label>
|
|
<Select
|
|
value={providerType}
|
|
onValueChange={(value) =>
|
|
setProviderType(value as AiProviderType)
|
|
}
|
|
>
|
|
<SelectTrigger className="rounded-none">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{PROVIDER_TYPES.map((entry) => (
|
|
<SelectItem key={entry.value} value={entry.value}>
|
|
{t(entry.labelKey)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<Label>{t("ai.providerLabel")}</Label>
|
|
<Input
|
|
className="rounded-none"
|
|
value={label}
|
|
onChange={(event) => setLabel(event.target.value)}
|
|
placeholder={t("ai.providerLabelPlaceholder")}
|
|
/>
|
|
</div>
|
|
|
|
{spec.needsBaseUrl && (
|
|
<div className="space-y-1.5">
|
|
<Label>{t("ai.baseUrl")}</Label>
|
|
<Input
|
|
className="rounded-none"
|
|
value={baseUrl}
|
|
onChange={(event) => setBaseUrl(event.target.value)}
|
|
placeholder="http://localhost:11434"
|
|
/>
|
|
<p className="text-xs text-muted-foreground">
|
|
{t("ai.privateEndpointHint")}
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{(spec.needsApiKey || providerType === "openai_compatible") && (
|
|
<div className="space-y-1.5">
|
|
<Label>{t("ai.apiKey")}</Label>
|
|
<Input
|
|
className="rounded-none"
|
|
type="password"
|
|
value={apiKey}
|
|
onChange={(event) => setApiKey(event.target.value)}
|
|
autoComplete="off"
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center gap-2">
|
|
<Label className="min-w-0 flex-1">{t("ai.defaultModel")}</Label>
|
|
<button
|
|
type="button"
|
|
className="flex shrink-0 items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground"
|
|
onClick={() => void detectModels()}
|
|
disabled={detecting}
|
|
>
|
|
{detecting ? (
|
|
<Loader2 size={11} className="animate-spin" />
|
|
) : (
|
|
<RefreshCw size={11} />
|
|
)}
|
|
{t("ai.modelRefresh")}
|
|
</button>
|
|
</div>
|
|
|
|
{models.length > 0 && !customModel ? (
|
|
<Select
|
|
value={defaultModel || undefined}
|
|
onValueChange={(value) => {
|
|
if (value === "__custom__") {
|
|
setCustomModel(true);
|
|
setDefaultModel("");
|
|
return;
|
|
}
|
|
setDefaultModel(value);
|
|
}}
|
|
>
|
|
<SelectTrigger className="rounded-none">
|
|
<SelectValue placeholder={t("ai.modelPlaceholder")} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{models.map((model) => (
|
|
<SelectItem key={model} value={model}>
|
|
{model}
|
|
</SelectItem>
|
|
))}
|
|
{/* Anything the provider did not list is still reachable. */}
|
|
<SelectItem value="__custom__">
|
|
{t("ai.modelCustom")}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
<Input
|
|
className="rounded-none"
|
|
value={defaultModel}
|
|
onChange={(event) => setDefaultModel(event.target.value)}
|
|
placeholder={t("ai.defaultModelPlaceholder")}
|
|
/>
|
|
)}
|
|
|
|
{detectWarning && (
|
|
<p className="text-[11px] leading-snug text-muted-foreground">
|
|
{detectWarning}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
|
|
disabled={saving}
|
|
onClick={handleAdd}
|
|
>
|
|
{saving && <Loader2 size={14} className="animate-spin" />}
|
|
{t("ai.save")}
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
disabled={saving}
|
|
onClick={() => setAdding(false)}
|
|
>
|
|
{t("ai.cancel")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|