Migrate local inference model management to ACP (#10124)
This commit is contained in:
@@ -1,13 +1,12 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import {
|
||||
listLocalModels,
|
||||
syncFeaturedModels,
|
||||
downloadHfModel,
|
||||
getLocalModelDownloadProgress,
|
||||
cancelLocalModelDownload,
|
||||
type DownloadProgress,
|
||||
type LocalModelResponse,
|
||||
} from '../../api';
|
||||
} from '../../acp/local-inference';
|
||||
import { trackOnboardingSetupFailed } from '../../utils/analytics';
|
||||
import { defineMessages, useIntl } from '../../i18n';
|
||||
|
||||
@@ -124,16 +123,15 @@ export default function LocalModelPicker({ onConfigured }: LocalModelPickerProps
|
||||
useEffect(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
await syncFeaturedModels();
|
||||
const response = await listLocalModels({ throwOnError: true });
|
||||
if (response.data) {
|
||||
setModels(response.data);
|
||||
const models = await listLocalModels();
|
||||
if (models) {
|
||||
setModels(models);
|
||||
|
||||
const alreadyDownloaded = response.data.find((m) => m.status.state === 'Downloaded');
|
||||
const alreadyDownloaded = models.find((m) => m.status.state === 'Downloaded');
|
||||
if (alreadyDownloaded) {
|
||||
setSelectedModelId(alreadyDownloaded.id);
|
||||
} else {
|
||||
const recommended = response.data.find((m: LocalModelResponse) => m.recommended);
|
||||
const recommended = models.find((m: LocalModelResponse) => m.recommended);
|
||||
if (recommended) setSelectedModelId(recommended.id);
|
||||
}
|
||||
}
|
||||
@@ -165,7 +163,7 @@ export default function LocalModelPicker({ onConfigured }: LocalModelPickerProps
|
||||
}
|
||||
|
||||
try {
|
||||
await downloadHfModel({ body: { spec: model.id }, throwOnError: true });
|
||||
await downloadHfModel({ spec: model.id });
|
||||
} catch (error) {
|
||||
console.error('Failed to start download:', error);
|
||||
setErrorMessage(intl.formatMessage(i18n.failedToStartDownload));
|
||||
@@ -176,24 +174,27 @@ export default function LocalModelPicker({ onConfigured }: LocalModelPickerProps
|
||||
|
||||
pollRef.current = setInterval(async () => {
|
||||
try {
|
||||
const response = await getLocalModelDownloadProgress({
|
||||
path: { model_id: modelId },
|
||||
throwOnError: true,
|
||||
});
|
||||
if (response.data) {
|
||||
setDownloadProgress(response.data);
|
||||
if (response.data.status === 'completed') {
|
||||
cleanup();
|
||||
finishSetup(modelId);
|
||||
} else if (response.data.status === 'failed') {
|
||||
cleanup();
|
||||
setErrorMessage(response.data.error || 'Download failed.');
|
||||
trackOnboardingSetupFailed(LOCAL_PROVIDER, response.data.error || 'download_failed');
|
||||
setPhase('error');
|
||||
} else if (response.data.status === 'cancelled') {
|
||||
cleanup();
|
||||
setPhase('select');
|
||||
}
|
||||
const progress = await getLocalModelDownloadProgress(modelId);
|
||||
if (!progress) {
|
||||
cleanup();
|
||||
setErrorMessage(intl.formatMessage(i18n.lostConnection));
|
||||
trackOnboardingSetupFailed(LOCAL_PROVIDER, 'progress_missing');
|
||||
setPhase('error');
|
||||
return;
|
||||
}
|
||||
|
||||
setDownloadProgress(progress);
|
||||
if (progress.status === 'completed') {
|
||||
cleanup();
|
||||
finishSetup(modelId);
|
||||
} else if (progress.status === 'failed') {
|
||||
cleanup();
|
||||
setErrorMessage(progress.error || 'Download failed.');
|
||||
trackOnboardingSetupFailed(LOCAL_PROVIDER, progress.error || 'download_failed');
|
||||
setPhase('error');
|
||||
} else if (progress.status === 'cancelled') {
|
||||
cleanup();
|
||||
setPhase('select');
|
||||
}
|
||||
} catch {
|
||||
cleanup();
|
||||
@@ -208,7 +209,7 @@ export default function LocalModelPicker({ onConfigured }: LocalModelPickerProps
|
||||
if (phase === 'downloading' && selectedModelId) {
|
||||
cleanup();
|
||||
try {
|
||||
await cancelLocalModelDownload({ path: { model_id: selectedModelId } });
|
||||
await cancelLocalModelDownload(selectedModelId);
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
@@ -296,7 +297,7 @@ export default function LocalModelPicker({ onConfigured }: LocalModelPickerProps
|
||||
)}
|
||||
</div>
|
||||
<p className="text-text-muted text-xs mt-1">
|
||||
{formatSize(recommended.size_bytes)}
|
||||
{formatSize(recommended.sizeBytes)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -350,7 +351,7 @@ export default function LocalModelPicker({ onConfigured }: LocalModelPickerProps
|
||||
{model.id}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted">
|
||||
{formatSize(model.size_bytes)}
|
||||
{formatSize(model.sizeBytes)}
|
||||
</span>
|
||||
{model.status.state === 'Downloaded' && (
|
||||
<span className="text-xs bg-green-600 text-white px-2 py-0.5 rounded-full">
|
||||
@@ -375,7 +376,7 @@ export default function LocalModelPicker({ onConfigured }: LocalModelPickerProps
|
||||
{selectedModel?.status.state === 'Downloaded'
|
||||
? intl.formatMessage(i18n.useModel, { modelId: selectedModel.id })
|
||||
: selectedModel
|
||||
? intl.formatMessage(i18n.downloadModel, { modelId: selectedModel.id, size: formatSize(selectedModel.size_bytes) })
|
||||
? intl.formatMessage(i18n.downloadModel, { modelId: selectedModel.id, size: formatSize(selectedModel.sizeBytes) })
|
||||
: intl.formatMessage(i18n.selectModel)}
|
||||
</button>
|
||||
|
||||
@@ -394,30 +395,30 @@ export default function LocalModelPicker({ onConfigured }: LocalModelPickerProps
|
||||
<div className="w-full bg-background-subtle rounded-full h-2 overflow-hidden">
|
||||
<div
|
||||
className="bg-blue-500 h-2 rounded-full transition-all duration-500 ease-out"
|
||||
style={{ width: `${downloadProgress.progress_percent}%` }}
|
||||
style={{ width: `${downloadProgress.progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-xs text-text-muted">
|
||||
<span>
|
||||
{formatBytes(downloadProgress.bytes_downloaded)} of{' '}
|
||||
{formatBytes(downloadProgress.total_bytes)}
|
||||
{formatBytes(downloadProgress.bytesDownloaded)} of{' '}
|
||||
{formatBytes(downloadProgress.totalBytes)}
|
||||
</span>
|
||||
<span>{downloadProgress.progress_percent.toFixed(0)}%</span>
|
||||
<span>{downloadProgress.progressPercent.toFixed(0)}%</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between text-xs text-text-muted">
|
||||
{downloadProgress.speed_bps ? (
|
||||
<span>{formatBytes(downloadProgress.speed_bps)}/s</span>
|
||||
{downloadProgress.speedBps ? (
|
||||
<span>{formatBytes(downloadProgress.speedBps)}/s</span>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
{downloadProgress.eta_seconds != null && downloadProgress.eta_seconds > 0 && (
|
||||
{downloadProgress.etaSeconds != null && downloadProgress.etaSeconds > 0 && (
|
||||
<span>
|
||||
~
|
||||
{downloadProgress.eta_seconds < 60
|
||||
? `${Math.round(downloadProgress.eta_seconds)}s`
|
||||
: `${Math.round(downloadProgress.eta_seconds / 60)}m`}{' '}
|
||||
{downloadProgress.etaSeconds < 60
|
||||
? `${Math.round(downloadProgress.etaSeconds)}s`
|
||||
: `${Math.round(downloadProgress.etaSeconds / 60)}m`}{' '}
|
||||
remaining
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
type DownloadModelRequest,
|
||||
type HfModelInfo,
|
||||
type HfModelVariant,
|
||||
} from '../../../api';
|
||||
type RepoVariantsResponse,
|
||||
} from '../../../acp/local-inference';
|
||||
import { defineMessages, useIntl } from '../../../i18n';
|
||||
|
||||
const i18n = defineMessages({
|
||||
@@ -127,61 +128,43 @@ export const HuggingFaceModelSearch = ({
|
||||
setSearching(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await searchHfModels({
|
||||
query: { q, limit: 20 },
|
||||
});
|
||||
if (response.data) {
|
||||
// Pre-fetch variants for all results and filter out repos with no compatible local variants
|
||||
const modelsWithVariants = await Promise.all(
|
||||
response.data.map(async (model) => {
|
||||
try {
|
||||
const [author, repo] = model.repo_id.split('/');
|
||||
const filesResponse = await getRepoFiles({ path: { author, repo } });
|
||||
if (filesResponse.data && filesResponse.data.variants.length > 0) {
|
||||
return { model, data: filesResponse.data };
|
||||
}
|
||||
} catch {
|
||||
// Skip repos we can't fetch
|
||||
const models = await searchHfModels(q, 20);
|
||||
const modelsWithVariants = await Promise.all(
|
||||
models.map(async (model) => {
|
||||
try {
|
||||
const repoData = await getRepoFiles(model.repoId);
|
||||
if (repoData.variants.length > 0) {
|
||||
return { model, data: repoData };
|
||||
}
|
||||
return null;
|
||||
})
|
||||
);
|
||||
|
||||
const validResults = modelsWithVariants.filter(Boolean) as {
|
||||
model: HfModelInfo;
|
||||
data: {
|
||||
variants: HfModelVariant[];
|
||||
recommended_index?: number | null;
|
||||
available_memory_bytes: number;
|
||||
downloaded_quants: string[];
|
||||
downloaded_variants: string[];
|
||||
};
|
||||
}[];
|
||||
|
||||
setResults(validResults.map((r) => r.model));
|
||||
setRepoData((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const r of validResults) {
|
||||
next[r.model.repo_id] = {
|
||||
variants: r.data.variants,
|
||||
recommendedIndex: r.data.recommended_index ?? null,
|
||||
availableMemoryBytes: r.data.available_memory_bytes,
|
||||
downloadedQuants: new Set(r.data.downloaded_quants),
|
||||
downloadedVariants: new Set(r.data.downloaded_variants),
|
||||
};
|
||||
} catch {
|
||||
// Skip repos we can't fetch
|
||||
}
|
||||
return next;
|
||||
});
|
||||
return null;
|
||||
})
|
||||
);
|
||||
|
||||
if (validResults.length === 0) {
|
||||
setError(intl.formatMessage(i18n.noGgufModels));
|
||||
const validResults = modelsWithVariants.filter(Boolean) as {
|
||||
model: HfModelInfo;
|
||||
data: RepoVariantsResponse;
|
||||
}[];
|
||||
|
||||
setResults(validResults.map((r) => r.model));
|
||||
setRepoData((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const r of validResults) {
|
||||
next[r.model.repoId] = {
|
||||
variants: r.data.variants,
|
||||
recommendedIndex: r.data.recommendedIndex ?? null,
|
||||
availableMemoryBytes: r.data.availableMemoryBytes,
|
||||
downloadedQuants: new Set(r.data.downloadedQuants),
|
||||
downloadedVariants: new Set(r.data.downloadedVariants),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
console.error('Search response:', response);
|
||||
const errMsg = response.error
|
||||
? intl.formatMessage(i18n.searchError, { details: JSON.stringify(response.error) })
|
||||
: intl.formatMessage(i18n.searchNoData);
|
||||
setError(errMsg);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (validResults.length === 0) {
|
||||
setError(intl.formatMessage(i18n.noGgufModels));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Search failed:', e);
|
||||
@@ -209,22 +192,17 @@ export const HuggingFaceModelSearch = ({
|
||||
if (!repoData[repoId]?.variants.length) {
|
||||
setLoadingFiles((prev) => new Set(prev).add(repoId));
|
||||
try {
|
||||
const [author, repo] = repoId.split('/');
|
||||
const response = await getRepoFiles({
|
||||
path: { author, repo },
|
||||
});
|
||||
if (response.data) {
|
||||
setRepoData((prev) => ({
|
||||
...prev,
|
||||
[repoId]: {
|
||||
variants: response.data!.variants,
|
||||
recommendedIndex: response.data!.recommended_index ?? null,
|
||||
availableMemoryBytes: response.data!.available_memory_bytes,
|
||||
downloadedQuants: new Set(response.data!.downloaded_quants),
|
||||
downloadedVariants: new Set(response.data!.downloaded_variants),
|
||||
},
|
||||
}));
|
||||
}
|
||||
const response = await getRepoFiles(repoId);
|
||||
setRepoData((prev) => ({
|
||||
...prev,
|
||||
[repoId]: {
|
||||
variants: response.variants,
|
||||
recommendedIndex: response.recommendedIndex ?? null,
|
||||
availableMemoryBytes: response.availableMemoryBytes,
|
||||
downloadedQuants: new Set(response.downloadedQuants),
|
||||
downloadedVariants: new Set(response.downloadedVariants),
|
||||
},
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch repo files:', e);
|
||||
} finally {
|
||||
@@ -238,20 +216,16 @@ export const HuggingFaceModelSearch = ({
|
||||
};
|
||||
|
||||
const startDownload = async (repoId: string, variant: HfModelVariant) => {
|
||||
const downloadKey = variant.download_id;
|
||||
const downloadKey = variant.downloadId;
|
||||
const request: DownloadModelRequest = {
|
||||
spec: repoId,
|
||||
backend_id: variant.backend_id,
|
||||
variant_id: variant.variant_id,
|
||||
backendId: variant.backendId,
|
||||
variantId: variant.variantId,
|
||||
};
|
||||
setDownloading((prev) => new Set(prev).add(downloadKey));
|
||||
try {
|
||||
const response = await downloadHfModel({
|
||||
body: request,
|
||||
});
|
||||
if (response.data) {
|
||||
onDownloadStarted(response.data, request);
|
||||
}
|
||||
const modelId = await downloadHfModel(request);
|
||||
onDownloadStarted(modelId, request);
|
||||
} catch (e) {
|
||||
console.error('Download failed:', e);
|
||||
} finally {
|
||||
@@ -289,8 +263,8 @@ export const HuggingFaceModelSearch = ({
|
||||
{results.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{results.map((model) => {
|
||||
const isExpanded = expandedRepo === model.repo_id;
|
||||
const data = repoData[model.repo_id];
|
||||
const isExpanded = expandedRepo === model.repoId;
|
||||
const data = repoData[model.repoId];
|
||||
const variants = data?.variants || [];
|
||||
const recommendedIndex = data?.recommendedIndex ?? null;
|
||||
const availableMemory = data?.availableMemoryBytes ?? 0;
|
||||
@@ -298,15 +272,15 @@ export const HuggingFaceModelSearch = ({
|
||||
const downloadedVariants = data?.downloadedVariants ?? new Set<string>();
|
||||
|
||||
return (
|
||||
<div key={model.repo_id} className="border border-border-subtle rounded-lg">
|
||||
<div key={model.repoId} className="border border-border-subtle rounded-lg">
|
||||
<button
|
||||
onClick={() => toggleRepo(model.repo_id)}
|
||||
onClick={() => toggleRepo(model.repoId)}
|
||||
className="w-full flex items-center justify-between p-3 text-left hover:bg-background-subtle rounded-lg"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-text-default truncate">
|
||||
{model.repo_id}
|
||||
{model.repoId}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mt-0.5">
|
||||
@@ -324,28 +298,28 @@ export const HuggingFaceModelSearch = ({
|
||||
|
||||
{isExpanded && (
|
||||
<div className="border-t border-border-subtle px-3 pb-3 space-y-1">
|
||||
{loadingFiles.has(model.repo_id) && (
|
||||
{loadingFiles.has(model.repoId) && (
|
||||
<div className="flex items-center gap-2 py-2 text-xs text-text-muted">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
{intl.formatMessage(i18n.loadingVariants)}
|
||||
</div>
|
||||
)}
|
||||
{variants.map((variant, idx) => {
|
||||
const isStarting = downloading.has(variant.download_id);
|
||||
const isStarting = downloading.has(variant.downloadId);
|
||||
const isRecommended = idx === recommendedIndex;
|
||||
const modelId = variant.model_id;
|
||||
const modelId = variant.modelId;
|
||||
const isActiveDownload = activeDownloadIds?.has(modelId) ?? false;
|
||||
const isDownloaded = downloadedModelIds
|
||||
? downloadedModelIds.has(modelId)
|
||||
: downloadedVariants.has(modelId) ||
|
||||
downloadedQuants.has(variant.variant_id);
|
||||
downloadedQuants.has(variant.variantId);
|
||||
const tooLarge =
|
||||
availableMemory > 0 && variant.size_bytes > availableMemory * 0.85;
|
||||
availableMemory > 0 && variant.sizeBytes > availableMemory * 0.85;
|
||||
const isSupported = variant.supported ?? true;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={variant.download_id}
|
||||
key={variant.downloadId}
|
||||
className={`flex items-center justify-between py-2 px-2 rounded ${
|
||||
isDownloaded
|
||||
? 'bg-green-500/5 border border-green-500/20'
|
||||
@@ -363,7 +337,7 @@ export const HuggingFaceModelSearch = ({
|
||||
{variant.label}
|
||||
</span>
|
||||
<span className="text-xs text-text-muted">
|
||||
{formatBytes(variant.size_bytes)}
|
||||
{formatBytes(variant.sizeBytes)}
|
||||
</span>
|
||||
{isRecommended && !isDownloaded && (
|
||||
<span className="inline-flex items-center gap-1 text-xs bg-blue-500 text-white px-1.5 py-0.5 rounded">
|
||||
@@ -375,17 +349,17 @@ export const HuggingFaceModelSearch = ({
|
||||
{variant.description && (
|
||||
<span className="text-xs text-text-muted">{variant.description}</span>
|
||||
)}
|
||||
{!isSupported && variant.unsupported_reason && (
|
||||
{!isSupported && variant.unsupportedReason && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-amber-500">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{variant.unsupported_reason}
|
||||
{variant.unsupportedReason}
|
||||
</span>
|
||||
)}
|
||||
{tooLarge && !isDownloaded && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-amber-500">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{intl.formatMessage(i18n.tooLarge, {
|
||||
size: formatBytes(variant.size_bytes),
|
||||
size: formatBytes(variant.sizeBytes),
|
||||
available: formatBytes(availableMemory),
|
||||
})}
|
||||
</span>
|
||||
@@ -406,7 +380,7 @@ export const HuggingFaceModelSearch = ({
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isStarting || !isSupported}
|
||||
onClick={() => startDownload(model.repo_id, variant)}
|
||||
onClick={() => startDownload(model.repoId, variant)}
|
||||
>
|
||||
{isStarting ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
|
||||
@@ -14,7 +14,6 @@ import { useModelAndProvider } from '../../ModelAndProviderContext';
|
||||
import { defineMessages, useIntl } from '../../../i18n';
|
||||
import {
|
||||
listLocalModels,
|
||||
syncFeaturedModels,
|
||||
downloadHfModel,
|
||||
getLocalModelDownloadProgress,
|
||||
cancelLocalModelDownload,
|
||||
@@ -22,7 +21,7 @@ import {
|
||||
type DownloadProgress,
|
||||
type DownloadModelRequest,
|
||||
type LocalModelResponse,
|
||||
} from '../../../api';
|
||||
} from '../../../acp/local-inference';
|
||||
import { HuggingFaceModelSearch } from './HuggingFaceModelSearch';
|
||||
import { ModelSettingsPanel } from './ModelSettingsPanel';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../../ui/dialog';
|
||||
@@ -133,9 +132,9 @@ const VisionBadge = ({
|
||||
model: LocalModelResponse;
|
||||
intl: ReturnType<typeof useIntl>;
|
||||
}) => {
|
||||
if (!model.vision_capable) return null;
|
||||
if (!model.visionCapable) return null;
|
||||
|
||||
const mmproj = model.mmproj_status;
|
||||
const mmproj = model.mmprojStatus;
|
||||
const isDownloaded = mmproj?.state === 'Downloaded';
|
||||
const isDownloading = mmproj?.state === 'Downloading';
|
||||
|
||||
@@ -150,7 +149,7 @@ const VisionBadge = ({
|
||||
|
||||
if (isDownloading) {
|
||||
const percent =
|
||||
mmproj && 'progress_percent' in mmproj ? Math.round(mmproj.progress_percent) : null;
|
||||
mmproj && mmproj.progressPercent != null ? Math.round(mmproj.progressPercent) : null;
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-yellow-400 bg-yellow-500/10 px-2 py-0.5 rounded">
|
||||
<Eye className="w-3 h-3" />
|
||||
@@ -191,12 +190,11 @@ export const LocalInferenceSettings = () => {
|
||||
|
||||
const loadModels = useCallback(async (): Promise<LocalModelResponse[] | undefined> => {
|
||||
try {
|
||||
await syncFeaturedModels();
|
||||
const response = await listLocalModels();
|
||||
if (response.data) {
|
||||
setModels(response.data);
|
||||
const models = await listLocalModels();
|
||||
if (models) {
|
||||
setModels(models);
|
||||
const downloadedIds = new Set(
|
||||
response.data
|
||||
models
|
||||
.filter((model) => model.status.state === 'Downloaded')
|
||||
.map((model) => model.id)
|
||||
);
|
||||
@@ -212,13 +210,13 @@ export const LocalInferenceSettings = () => {
|
||||
return next;
|
||||
});
|
||||
}
|
||||
response.data.forEach((model) => {
|
||||
models.forEach((model) => {
|
||||
if (model.status.state === 'Downloading') {
|
||||
pollDownloadProgress(model.id);
|
||||
}
|
||||
});
|
||||
|
||||
return response.data;
|
||||
return models;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load models:', error);
|
||||
@@ -235,7 +233,7 @@ export const LocalInferenceSettings = () => {
|
||||
// Poll model list while any vision encoder is downloading
|
||||
useEffect(() => {
|
||||
const hasDownloadingMmproj = models.some(
|
||||
(m) => m.vision_capable && m.mmproj_status?.state === 'Downloading'
|
||||
(m) => m.visionCapable && m.mmprojStatus?.state === 'Downloading'
|
||||
);
|
||||
if (!hasDownloadingMmproj) return;
|
||||
|
||||
@@ -259,7 +257,7 @@ export const LocalInferenceSettings = () => {
|
||||
if (!model) return;
|
||||
const request = { spec: model.id };
|
||||
try {
|
||||
await downloadHfModel({ body: request });
|
||||
await downloadHfModel(request);
|
||||
setDownloadRequests((prev) => new Map(prev).set(modelId, request));
|
||||
pollDownloadProgress(modelId);
|
||||
scrollToDownloads();
|
||||
@@ -285,9 +283,8 @@ export const LocalInferenceSettings = () => {
|
||||
|
||||
const interval = setInterval(async () => {
|
||||
try {
|
||||
const response = await getLocalModelDownloadProgress({ path: { model_id: modelId } });
|
||||
if (response.data) {
|
||||
const progress = response.data;
|
||||
const progress = await getLocalModelDownloadProgress(modelId);
|
||||
if (progress) {
|
||||
setDownloads((prev) => new Map(prev).set(modelId, progress));
|
||||
|
||||
if (progress.status === 'completed') {
|
||||
@@ -314,7 +311,7 @@ export const LocalInferenceSettings = () => {
|
||||
|
||||
const cancelDownload = async (modelId: string) => {
|
||||
try {
|
||||
await cancelLocalModelDownload({ path: { model_id: modelId } });
|
||||
await cancelLocalModelDownload(modelId);
|
||||
setDownloads((prev) => {
|
||||
const next = new Map(prev);
|
||||
const progress = next.get(modelId);
|
||||
@@ -346,8 +343,7 @@ export const LocalInferenceSettings = () => {
|
||||
const request = downloadRequests.get(modelId) ?? { spec: modelId };
|
||||
dismissDownload(modelId);
|
||||
try {
|
||||
const response = await downloadHfModel({ body: request });
|
||||
const nextModelId = response.data ?? modelId;
|
||||
const nextModelId = await downloadHfModel(request);
|
||||
setDownloadRequests((prev) => new Map(prev).set(nextModelId, request));
|
||||
pollDownloadProgress(nextModelId);
|
||||
scrollToDownloads();
|
||||
@@ -359,7 +355,7 @@ export const LocalInferenceSettings = () => {
|
||||
const handleDeleteModel = async (modelId: string) => {
|
||||
if (!window.confirm(intl.formatMessage(i18n.deleteConfirm))) return;
|
||||
try {
|
||||
await deleteLocalModel({ path: { model_id: modelId } });
|
||||
await deleteLocalModel(modelId);
|
||||
const updatedModels = await loadModels();
|
||||
|
||||
if (selectedModelId === modelId && updatedModels) {
|
||||
@@ -442,30 +438,30 @@ export const LocalInferenceSettings = () => {
|
||||
<div className="w-full bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-500 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progress.progress_percent}%` }}
|
||||
style={{ width: `${progress.progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between text-xs text-text-muted">
|
||||
<span>
|
||||
{intl.formatMessage(i18n.downloadProgress, {
|
||||
downloaded: formatBytes(progress.bytes_downloaded),
|
||||
total: formatBytes(progress.total_bytes),
|
||||
percent: progress.progress_percent.toFixed(0),
|
||||
downloaded: formatBytes(progress.bytesDownloaded),
|
||||
total: formatBytes(progress.totalBytes),
|
||||
percent: progress.progressPercent.toFixed(0),
|
||||
})}
|
||||
</span>
|
||||
<span className="flex gap-2">
|
||||
{progress.eta_seconds != null && progress.eta_seconds > 0 && (
|
||||
{progress.etaSeconds != null && progress.etaSeconds > 0 && (
|
||||
<span>
|
||||
{intl.formatMessage(i18n.remaining, {
|
||||
time:
|
||||
progress.eta_seconds < 60
|
||||
? `${Math.round(progress.eta_seconds)}s`
|
||||
: `${Math.round(progress.eta_seconds / 60)}m`,
|
||||
progress.etaSeconds < 60
|
||||
? `${Math.round(progress.etaSeconds)}s`
|
||||
: `${Math.round(progress.etaSeconds / 60)}m`,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{progress.speed_bps != null && progress.speed_bps > 0 && (
|
||||
<span>{formatBytes(progress.speed_bps)}/s</span>
|
||||
{progress.speedBps != null && progress.speedBps > 0 && (
|
||||
<span>{formatBytes(progress.speedBps)}/s</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -540,7 +536,7 @@ export const LocalInferenceSettings = () => {
|
||||
/>
|
||||
<span className="text-sm font-medium text-text-default">{model.id}</span>
|
||||
<span className="text-xs text-text-muted">
|
||||
{formatBytes(model.size_bytes)}
|
||||
{formatBytes(model.sizeBytes)}
|
||||
</span>
|
||||
{model.recommended && (
|
||||
<span className="text-xs bg-blue-500 text-white px-2 py-0.5 rounded">
|
||||
@@ -592,7 +588,7 @@ export const LocalInferenceSettings = () => {
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<h4 className="text-sm font-medium text-text-default">{model.id}</h4>
|
||||
<span className="text-xs text-text-muted">
|
||||
{formatBytes(model.size_bytes)}
|
||||
{formatBytes(model.sizeBytes)}
|
||||
</span>
|
||||
{model.recommended && (
|
||||
<span className="text-xs bg-blue-500 text-white px-2 py-0.5 rounded">
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
type ModelSettings,
|
||||
type SamplingConfig,
|
||||
type ToolCallingMode,
|
||||
} from '../../../api';
|
||||
} from '../../../acp/local-inference';
|
||||
import { defineMessages, useIntl } from '../../../i18n';
|
||||
|
||||
const i18n = defineMessages({
|
||||
@@ -221,27 +221,33 @@ const i18n = defineMessages({
|
||||
});
|
||||
|
||||
const DEFAULT_SETTINGS: ModelSettings = {
|
||||
context_size: null,
|
||||
max_output_tokens: null,
|
||||
backendId: null,
|
||||
contextSize: null,
|
||||
maxOutputTokens: null,
|
||||
draftModel: null,
|
||||
sampling: {
|
||||
type: 'Temperature',
|
||||
temperature: 0.8,
|
||||
top_k: 40,
|
||||
top_p: 0.95,
|
||||
min_p: 0.05,
|
||||
topK: 40,
|
||||
topP: 0.95,
|
||||
minP: 0.05,
|
||||
seed: null,
|
||||
},
|
||||
repeat_penalty: 1.0,
|
||||
repeat_last_n: 64,
|
||||
frequency_penalty: 0.0,
|
||||
presence_penalty: 0.0,
|
||||
n_batch: null,
|
||||
n_gpu_layers: null,
|
||||
use_mlock: false,
|
||||
flash_attention: null,
|
||||
n_threads: null,
|
||||
tool_calling: 'auto',
|
||||
chat_template: { type: 'embedded' },
|
||||
repeatPenalty: 1.0,
|
||||
repeatLastN: 64,
|
||||
frequencyPenalty: 0.0,
|
||||
presencePenalty: 0.0,
|
||||
nBatch: null,
|
||||
nGpuLayers: null,
|
||||
useMlock: false,
|
||||
flashAttention: null,
|
||||
nThreads: null,
|
||||
toolCalling: 'auto',
|
||||
chatTemplate: { type: 'embedded' },
|
||||
enableThinking: true,
|
||||
visionCapable: false,
|
||||
imageTokenEstimate: 256,
|
||||
mmprojSizeBytes: 0,
|
||||
};
|
||||
|
||||
type SamplingType = SamplingConfig['type'];
|
||||
@@ -390,17 +396,17 @@ export const ModelSettingsPanel = ({ modelId }: { modelId: string }) => {
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [settingsResult, builtinsResult] = await Promise.allSettled([
|
||||
getModelSettings({ path: { model_id: modelId } }),
|
||||
getModelSettings(modelId),
|
||||
listBuiltinChatTemplates(),
|
||||
]);
|
||||
if (builtinsResult.status === 'fulfilled' && builtinsResult.value.data?.length) {
|
||||
setBuiltinTemplateOptions(builtinsResult.value.data);
|
||||
if (builtinsResult.status === 'fulfilled' && builtinsResult.value.length) {
|
||||
setBuiltinTemplateOptions(builtinsResult.value);
|
||||
}
|
||||
if (settingsResult.status === 'fulfilled' && settingsResult.value.data) {
|
||||
if (settingsResult.status === 'fulfilled') {
|
||||
setSettings({
|
||||
...settingsResult.value.data,
|
||||
tool_calling: settingsResult.value.data.tool_calling ?? 'auto',
|
||||
chat_template: settingsResult.value.data.chat_template ?? { type: 'embedded' },
|
||||
...settingsResult.value,
|
||||
toolCalling: settingsResult.value.toolCalling ?? 'auto',
|
||||
chatTemplate: settingsResult.value.chatTemplate ?? { type: 'embedded' },
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
@@ -415,7 +421,7 @@ export const ModelSettingsPanel = ({ modelId }: { modelId: string }) => {
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
const chatTemplate = settings.chat_template;
|
||||
const chatTemplate = settings.chatTemplate;
|
||||
if (chatTemplate?.type === 'custom_inline') {
|
||||
setChatTemplateDraft(chatTemplate.template ?? '');
|
||||
} else {
|
||||
@@ -424,13 +430,13 @@ export const ModelSettingsPanel = ({ modelId }: { modelId: string }) => {
|
||||
if (chatTemplate?.type === 'builtin') {
|
||||
setBuiltinTemplateDraft(chatTemplate.name ?? 'chatml');
|
||||
}
|
||||
}, [settings.chat_template]);
|
||||
}, [settings.chatTemplate]);
|
||||
|
||||
const save = async (updated: ModelSettings) => {
|
||||
setSettings(updated);
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateModelSettings({ path: { model_id: modelId }, body: updated });
|
||||
await updateModelSettings(modelId, updated);
|
||||
} catch (e) {
|
||||
console.error('Failed to save settings:', e);
|
||||
} finally {
|
||||
@@ -445,7 +451,7 @@ export const ModelSettingsPanel = ({ modelId }: { modelId: string }) => {
|
||||
};
|
||||
|
||||
const samplingType: SamplingType = settings.sampling?.type ?? 'Temperature';
|
||||
const chatTemplate = settings.chat_template ?? { type: 'embedded' };
|
||||
const chatTemplate = settings.chatTemplate ?? { type: 'embedded' };
|
||||
const chatTemplateMode: ChatTemplateMode =
|
||||
chatTemplate.type === 'custom_inline'
|
||||
? 'custom_inline'
|
||||
@@ -458,23 +464,26 @@ export const ModelSettingsPanel = ({ modelId }: { modelId: string }) => {
|
||||
if (mode === 'custom_inline') {
|
||||
next = { type: 'custom_inline', template: chatTemplateDraft };
|
||||
} else if (mode === 'builtin') {
|
||||
next = { type: 'builtin', name: builtinTemplateDraft.trim() || builtinTemplateOptions[0] || 'chatml' };
|
||||
next = {
|
||||
type: 'builtin',
|
||||
name: builtinTemplateDraft.trim() || builtinTemplateOptions[0] || 'chatml',
|
||||
};
|
||||
} else {
|
||||
next = { type: 'embedded' };
|
||||
}
|
||||
updateField('chat_template', next);
|
||||
updateField('chatTemplate', next);
|
||||
};
|
||||
|
||||
const setBuiltinTemplateName = (name: string) => {
|
||||
setBuiltinTemplateDraft(name);
|
||||
if (chatTemplateMode === 'builtin') {
|
||||
updateField('chat_template', { type: 'builtin', name });
|
||||
updateField('chatTemplate', { type: 'builtin', name });
|
||||
}
|
||||
};
|
||||
|
||||
const saveChatTemplateDraft = () => {
|
||||
if (chatTemplateMode === 'custom_inline') {
|
||||
updateField('chat_template', { type: 'custom_inline', template: chatTemplateDraft });
|
||||
updateField('chatTemplate', { type: 'custom_inline', template: chatTemplateDraft });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -488,9 +497,9 @@ export const ModelSettingsPanel = ({ modelId }: { modelId: string }) => {
|
||||
sampling = {
|
||||
type: 'Temperature',
|
||||
temperature: 0.8,
|
||||
top_k: 40,
|
||||
top_p: 0.95,
|
||||
min_p: 0.05,
|
||||
topK: 40,
|
||||
topP: 0.95,
|
||||
minP: 0.05,
|
||||
seed: null,
|
||||
};
|
||||
}
|
||||
@@ -526,8 +535,8 @@ export const ModelSettingsPanel = ({ modelId }: { modelId: string }) => {
|
||||
<NumberField
|
||||
label={intl.formatMessage(i18n.contextSize)}
|
||||
description={intl.formatMessage(i18n.contextSizeDescription)}
|
||||
value={settings.context_size}
|
||||
onChange={(v) => updateField('context_size', v)}
|
||||
value={settings.contextSize}
|
||||
onChange={(v) => updateField('contextSize', v)}
|
||||
placeholder="Auto"
|
||||
min={0}
|
||||
allowNull
|
||||
@@ -535,8 +544,8 @@ export const ModelSettingsPanel = ({ modelId }: { modelId: string }) => {
|
||||
<NumberField
|
||||
label={intl.formatMessage(i18n.maxOutputTokens)}
|
||||
description={intl.formatMessage(i18n.maxOutputTokensDescription)}
|
||||
value={settings.max_output_tokens}
|
||||
onChange={(v) => updateField('max_output_tokens', v)}
|
||||
value={settings.maxOutputTokens}
|
||||
onChange={(v) => updateField('maxOutputTokens', v)}
|
||||
placeholder="No limit"
|
||||
min={1}
|
||||
allowNull
|
||||
@@ -569,22 +578,22 @@ export const ModelSettingsPanel = ({ modelId }: { modelId: string }) => {
|
||||
/>
|
||||
<NumberField
|
||||
label={intl.formatMessage(i18n.topK)}
|
||||
value={settings.sampling.top_k}
|
||||
onChange={(v) => updateSampling({ top_k: v ?? 40 })}
|
||||
value={settings.sampling.topK}
|
||||
onChange={(v) => updateSampling({ topK: v ?? 40 })}
|
||||
min={0}
|
||||
/>
|
||||
<NumberField
|
||||
label={intl.formatMessage(i18n.topP)}
|
||||
value={settings.sampling.top_p}
|
||||
onChange={(v) => updateSampling({ top_p: v ?? 0.95 })}
|
||||
value={settings.sampling.topP}
|
||||
onChange={(v) => updateSampling({ topP: v ?? 0.95 })}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
/>
|
||||
<NumberField
|
||||
label={intl.formatMessage(i18n.minP)}
|
||||
value={settings.sampling.min_p}
|
||||
onChange={(v) => updateSampling({ min_p: v ?? 0.05 })}
|
||||
value={settings.sampling.minP}
|
||||
onChange={(v) => updateSampling({ minP: v ?? 0.05 })}
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
@@ -636,23 +645,23 @@ export const ModelSettingsPanel = ({ modelId }: { modelId: string }) => {
|
||||
<NumberField
|
||||
label={intl.formatMessage(i18n.repeatPenalty)}
|
||||
description={intl.formatMessage(i18n.repeatPenaltyDescription)}
|
||||
value={settings.repeat_penalty}
|
||||
onChange={(v) => updateField('repeat_penalty', v ?? 1.0)}
|
||||
value={settings.repeatPenalty}
|
||||
onChange={(v) => updateField('repeatPenalty', v ?? 1.0)}
|
||||
min={0}
|
||||
step={0.05}
|
||||
/>
|
||||
<NumberField
|
||||
label={intl.formatMessage(i18n.repeatWindow)}
|
||||
description={intl.formatMessage(i18n.repeatWindowDescription)}
|
||||
value={settings.repeat_last_n}
|
||||
onChange={(v) => updateField('repeat_last_n', v ?? 64)}
|
||||
value={settings.repeatLastN}
|
||||
onChange={(v) => updateField('repeatLastN', v ?? 64)}
|
||||
min={0}
|
||||
/>
|
||||
<NumberField
|
||||
label={intl.formatMessage(i18n.frequencyPenalty)}
|
||||
description={intl.formatMessage(i18n.frequencyPenaltyDescription)}
|
||||
value={settings.frequency_penalty}
|
||||
onChange={(v) => updateField('frequency_penalty', v ?? 0.0)}
|
||||
value={settings.frequencyPenalty}
|
||||
onChange={(v) => updateField('frequencyPenalty', v ?? 0.0)}
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.05}
|
||||
@@ -660,8 +669,8 @@ export const ModelSettingsPanel = ({ modelId }: { modelId: string }) => {
|
||||
<NumberField
|
||||
label={intl.formatMessage(i18n.presencePenalty)}
|
||||
description={intl.formatMessage(i18n.presencePenaltyDescription)}
|
||||
value={settings.presence_penalty}
|
||||
onChange={(v) => updateField('presence_penalty', v ?? 0.0)}
|
||||
value={settings.presencePenalty}
|
||||
onChange={(v) => updateField('presencePenalty', v ?? 0.0)}
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.05}
|
||||
@@ -676,8 +685,8 @@ export const ModelSettingsPanel = ({ modelId }: { modelId: string }) => {
|
||||
<NumberField
|
||||
label={intl.formatMessage(i18n.batchSize)}
|
||||
description={intl.formatMessage(i18n.batchSizeDescription)}
|
||||
value={settings.n_batch}
|
||||
onChange={(v) => updateField('n_batch', v)}
|
||||
value={settings.nBatch}
|
||||
onChange={(v) => updateField('nBatch', v)}
|
||||
placeholder="Auto"
|
||||
min={1}
|
||||
allowNull
|
||||
@@ -685,8 +694,8 @@ export const ModelSettingsPanel = ({ modelId }: { modelId: string }) => {
|
||||
<NumberField
|
||||
label={intl.formatMessage(i18n.gpuLayers)}
|
||||
description={intl.formatMessage(i18n.gpuLayersDescription)}
|
||||
value={settings.n_gpu_layers}
|
||||
onChange={(v) => updateField('n_gpu_layers', v)}
|
||||
value={settings.nGpuLayers}
|
||||
onChange={(v) => updateField('nGpuLayers', v)}
|
||||
placeholder="All"
|
||||
min={0}
|
||||
allowNull
|
||||
@@ -694,8 +703,8 @@ export const ModelSettingsPanel = ({ modelId }: { modelId: string }) => {
|
||||
<NumberField
|
||||
label={intl.formatMessage(i18n.threads)}
|
||||
description={intl.formatMessage(i18n.threadsDescription)}
|
||||
value={settings.n_threads}
|
||||
onChange={(v) => updateField('n_threads', v)}
|
||||
value={settings.nThreads}
|
||||
onChange={(v) => updateField('nThreads', v)}
|
||||
placeholder="Auto"
|
||||
min={1}
|
||||
allowNull
|
||||
@@ -704,16 +713,16 @@ export const ModelSettingsPanel = ({ modelId }: { modelId: string }) => {
|
||||
<ToggleField
|
||||
label={intl.formatMessage(i18n.lockModelInRam)}
|
||||
description={intl.formatMessage(i18n.lockModelInRamDescription)}
|
||||
value={settings.use_mlock ?? false}
|
||||
onChange={(v) => updateField('use_mlock', v)}
|
||||
value={settings.useMlock ?? false}
|
||||
onChange={(v) => updateField('useMlock', v)}
|
||||
/>
|
||||
<SelectField
|
||||
label={intl.formatMessage(i18n.flashAttention)}
|
||||
description={intl.formatMessage(i18n.flashAttentionDescription)}
|
||||
value={
|
||||
settings.flash_attention === null || settings.flash_attention === undefined
|
||||
settings.flashAttention === null || settings.flashAttention === undefined
|
||||
? 'auto'
|
||||
: settings.flash_attention
|
||||
: settings.flashAttention
|
||||
? 'on'
|
||||
: 'off'
|
||||
}
|
||||
@@ -722,18 +731,18 @@ export const ModelSettingsPanel = ({ modelId }: { modelId: string }) => {
|
||||
{ value: 'on', label: 'On' },
|
||||
{ value: 'off', label: 'Off' },
|
||||
]}
|
||||
onChange={(v) => updateField('flash_attention', v === 'auto' ? null : v === 'on')}
|
||||
onChange={(v) => updateField('flashAttention', v === 'auto' ? null : v === 'on')}
|
||||
/>
|
||||
<SelectField<ToolCallingMode>
|
||||
label={intl.formatMessage(i18n.toolCalling)}
|
||||
description={intl.formatMessage(i18n.toolCallingDescription)}
|
||||
value={settings.tool_calling ?? 'auto'}
|
||||
value={settings.toolCalling ?? 'auto'}
|
||||
options={[
|
||||
{ value: 'auto', label: intl.formatMessage(i18n.toolCallingAuto) },
|
||||
{ value: 'force_native', label: intl.formatMessage(i18n.toolCallingForceNative) },
|
||||
{ value: 'force_emulated', label: intl.formatMessage(i18n.toolCallingForceEmulated) },
|
||||
]}
|
||||
onChange={(v) => updateField('tool_calling', v)}
|
||||
onChange={(v) => updateField('toolCalling', v)}
|
||||
/>
|
||||
<SelectField<ChatTemplateMode>
|
||||
label={intl.formatMessage(i18n.chatTemplate)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ProviderDetails, ThinkingEffort, listLocalModels } from '../../../api';
|
||||
import { ProviderDetails, ThinkingEffort } from '../../../api';
|
||||
import { listLocalModels } from '../../../acp/local-inference';
|
||||
import { acpListProviderDetails, acpListProviderModels } from '../../../acp/providers';
|
||||
import { errorMessage as getErrorMessage } from '../../../utils/conversionUtils';
|
||||
|
||||
@@ -56,8 +57,7 @@ export async function fetchModelsForProviders(
|
||||
try {
|
||||
// For local provider, use listLocalModels and filter to only downloaded models
|
||||
if (p.name === 'local') {
|
||||
const response = await listLocalModels();
|
||||
const allModels = response.data || [];
|
||||
const allModels = await listLocalModels();
|
||||
const downloadedModels = allModels
|
||||
.filter((m) => m.status.state === 'Downloaded')
|
||||
.map((m) => ({ name: m.id, provider: p.name }) as Model);
|
||||
|
||||
Reference in New Issue
Block a user