Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions components/settings/AiSections.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { AdaptiveAiHardwarePanel } from './AdaptiveAiHardwarePanel';
import { AiProviderCard } from './AiProviderCard';
import { GpuMetricsPanel } from './GpuMetricsPanel';
import { LocalAiDownloadProgress } from './LocalAiDownloadProgress';
import { OllamaDevicePull } from './OllamaDevicePull';
import { ToggleSwitch } from './SettingsShared';

const KNOWN_OLLAMA_MODELS = new Set([
Expand Down Expand Up @@ -334,10 +335,23 @@ export const AdvancedAiSection: FC = () => {
{t('settings.advancedAi.modelDescription')}
</p>
{settings.advancedAi.provider === 'ollama' && (
<p className="text-xs text-[var(--sc-text-muted)] mb-3">
{t('settings.advancedAi.modelRecommendationsIntro')}{' '}
{RECOMMENDED_OLLAMA_MODEL_IDS.join(', ')}
</p>
<>
<p className="text-xs text-[var(--sc-text-muted)] mb-3">
{t('settings.advancedAi.modelRecommendationsIntro')}{' '}
{RECOMMENDED_OLLAMA_MODEL_IDS.join(', ')}
</p>
{/* QNBS-v3: device-aware recommendation + one-click pull (PR E). */}
<OllamaDevicePull
baseUrl={settings.advancedAi.ollamaBaseUrl}
onUseModel={(tag) => {
handleSettingChange('advancedAi', { ...settings.advancedAi, model: tag });
// QNBS-v3: recommended tags aren't in the known-options list, so applying one takes
// the custom-model path — sync the custom input so the selector isn't left stale.
if (isCustomOllamaModel(tag)) setCustomModelInput(tag.replace(/^ollama\//, ''));
}}
t={t}
/>
Comment thread
qnbs marked this conversation as resolved.
</>
)}
<Select
id="settings-ai-model"
Expand Down
166 changes: 166 additions & 0 deletions components/settings/OllamaDevicePull.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
/**
* OllamaDevicePull — device-aware Ollama model recommendation + one-click pull.
* QNBS-v3: profiles the device (getHealthReport) → getOllamaModelForDevice picks a tag, then
* pullOllamaModel streams /api/pull progress with cancel + error-retry. Self-contained so AiSections
* stays lean; all network failure modes (unreachable server, inline error, abort) are surfaced.
*/

import { type FC, useEffect, useRef, useState } from 'react';
import type { useTranslation } from '../../hooks/useTranslation';
import { getHealthReport } from '../../services/ai/deviceHealthService';
import {
getOllamaModelForDevice,
type OllamaDeviceRecommendation,
} from '../../services/ai/modelRecommendations';
import { logger } from '../../services/logger';
import { type OllamaPullProgress, pullOllamaModel } from '../../services/ollamaService';
import { Badge } from '../ui/Badge';
import { Button } from '../ui/Button';

type Translate = ReturnType<typeof useTranslation>['t'];

interface OllamaDevicePullProps {
baseUrl: string;
/** Apply the pulled model as the active Ollama model (e.g. set advancedAi.model). */
onUseModel: (ollamaModelTag: string) => void;
t: Translate;
}

type PullState = 'idle' | 'pulling' | 'success' | 'error';

export const OllamaDevicePull: FC<OllamaDevicePullProps> = ({ baseUrl, onUseModel, t }) => {
const [rec, setRec] = useState<OllamaDeviceRecommendation | null>(null);
const [state, setState] = useState<PullState>('idle');
const [progress, setProgress] = useState(0);
const [statusText, setStatusText] = useState('');
const [error, setError] = useState('');
const abortRef = useRef<AbortController | null>(null);

// QNBS-v3: profile the device once on mount; failure is non-fatal (just no recommendation chip).
useEffect(() => {
let active = true;
void getHealthReport()
.then((report) => {
if (active) setRec(getOllamaModelForDevice(report));
})
.catch((err: unknown) => logger.warn('Device profile for Ollama recommendation failed', err));
return () => {
active = false;
abortRef.current?.abort();
};
}, []);

if (!rec) return null;

const onProgress = (p: OllamaPullProgress) => {
setStatusText(p.status);
if (typeof p.progress === 'number') setProgress(p.progress);
};

const startPull = async () => {
// QNBS-v3: in-flight guard — never start a second pull while one is running, so two controllers
// can't race (and so a stale finally can't clear a newer request's abort ref).
if (abortRef.current) return;
const controller = new AbortController();
abortRef.current = controller;
setState('pulling');
setProgress(0);
setError('');
try {
await pullOllamaModel(rec.modelId, { baseUrl, signal: controller.signal, onProgress });
setState('success');
} catch (err) {
// QNBS-v3: an aborted pull returns to idle (user-initiated cancel), not the error state.
if ((err as { name?: string })?.name === 'AbortError') {
setState('idle');
return;
}
setError(err instanceof Error ? err.message : String(err));
setState('error');
} finally {
// QNBS-v3: only clear the ref if it still points to THIS controller — defends against a late
// resolution nulling a newer pull's controller and breaking its Cancel.
if (abortRef.current === controller) abortRef.current = null;
}
};

const pct = Math.round(progress * 100);

return (
<div className="mb-3 rounded-sc-md border border-[var(--sc-border-subtle)] p-3 space-y-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-medium text-[var(--sc-text-secondary)]">
{t('settings.advancedAi.ollamaPull.recommendedFor')}
</span>
<code className="text-xs text-[var(--sc-text-primary)]">{rec.modelId}</code>
<Badge variant="new">{t(`settings.advancedAi.ollamaPull.tier.${rec.tier}`)}</Badge>
<span className="text-[10px] text-[var(--sc-text-muted)]">{rec.sizeHint}</span>
</div>

{rec.downgradedForBattery && (
<p className="text-[10px] text-[var(--sc-warning-fg)]">
{t('settings.advancedAi.ollamaPull.batteryNote')}
</p>
)}

{state === 'pulling' && (
<div className="space-y-1">
<div
className="h-1.5 w-full overflow-hidden rounded-full bg-[var(--sc-surface-overlay)]/40"
role="progressbar"
aria-valuenow={pct}
aria-valuemin={0}
aria-valuemax={100}
aria-label={t('settings.advancedAi.ollamaPull.pulling')}
>
<div
className="h-full bg-[var(--sc-accent)] transition-[width]"
style={{ width: `${pct}%` }}
/>
</div>
<p className="text-[10px] text-[var(--sc-text-muted)]">
{statusText} {pct > 0 ? `· ${pct}%` : ''}
</p>
</div>
)}

{state === 'error' && (
<p className="text-[10px] text-[var(--sc-danger-fg)]" role="alert">
{error}
</p>
)}

<div className="flex items-center gap-2">
{state === 'idle' && (
<Button size="sm" variant="secondary" onClick={() => void startPull()}>
{t('settings.advancedAi.ollamaPull.pull')}
</Button>
)}
{state === 'pulling' && (
<Button size="sm" variant="ghost" onClick={() => abortRef.current?.abort()}>
{t('common.cancel')}
</Button>
)}
{state === 'error' && (
<Button size="sm" variant="secondary" onClick={() => void startPull()}>
{t('settings.advancedAi.ollamaPull.retry')}
</Button>
)}
{state === 'success' && (
<>
<span className="text-xs text-[var(--sc-success-fg)]">
{t('settings.advancedAi.ollamaPull.installed')}
</span>
<Button
size="sm"
variant="secondary"
onClick={() => onUseModel(`ollama/${rec.modelId}`)}
>
{t('settings.advancedAi.ollamaPull.useModel')}
</Button>
</>
)}
</div>
</div>
);
};
10 changes: 10 additions & 0 deletions locales/ar/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,16 @@
"settings.advancedAi.model": "نموذج الذكاء الاصطناعي",
"settings.advancedAi.modelDescription": "نموذج الذكاء الاصطناعي المُستخدَم لتوليد المشاهد والتحليل والميزات الأخرى المدعومة بالذكاء الاصطناعي. يعتمد توفّر النموذج على المزوّد المختار.",
"settings.advancedAi.modelRecommendationsIntro": "نقاط تفتيش محلية شائعة (تحقّق من الأسماء في بيئة تشغيلك):",
"settings.advancedAi.ollamaPull.recommendedFor": "موصى به لجهازك:",
"settings.advancedAi.ollamaPull.pull": "تنزيل النموذج",
"settings.advancedAi.ollamaPull.pulling": "جارٍ تنزيل النموذج…",
"settings.advancedAi.ollamaPull.retry": "إعادة المحاولة",
"settings.advancedAi.ollamaPull.installed": "مثبَّت",
"settings.advancedAi.ollamaPull.useModel": "استخدام هذا النموذج",
"settings.advancedAi.ollamaPull.batteryNote": "تم تقليل الحجم درجة واحدة لتوفير البطارية.",
"settings.advancedAi.ollamaPull.tier.high-end": "فائق الأداء",
"settings.advancedAi.ollamaPull.tier.mid-range": "متوسط",
"settings.advancedAi.ollamaPull.tier.low-end": "أساسي",
"settings.advancedAi.ragModeHint": "يمزج الوضع الهجين مطابقة الكلمات المفتاحية بالفهم الدلالي — الأفضل لمعظم القصص. أعد بناء الفهرس بعد التعديلات الكبيرة.",
"settings.advancedAi.ragModeHybrid": "هجين (دلالي + معجمي، موصى به)",
"settings.advancedAi.ragModeLabel": "وضع استرجاع السياق",
Expand Down
10 changes: 10 additions & 0 deletions locales/de/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,16 @@
"settings.advancedAi.model": "KI-Modell",
"settings.advancedAi.modelDescription": "Das KI-Modell für Szenengeneration, Analyse und andere KI-Funktionen. Die Verfügbarkeit hängt vom gewählten Anbieter ab.",
"settings.advancedAi.modelRecommendationsIntro": "Beliebte lokale Checkpoints (Namen im Runtime überprüfen):",
"settings.advancedAi.ollamaPull.recommendedFor": "Für dein Gerät empfohlen:",
"settings.advancedAi.ollamaPull.pull": "Modell laden",
"settings.advancedAi.ollamaPull.pulling": "Modell wird geladen…",
"settings.advancedAi.ollamaPull.retry": "Erneut versuchen",
"settings.advancedAi.ollamaPull.installed": "Installiert",
"settings.advancedAi.ollamaPull.useModel": "Dieses Modell verwenden",
"settings.advancedAi.ollamaPull.batteryNote": "Eine Größe kleiner gewählt, um Akku zu sparen.",
"settings.advancedAi.ollamaPull.tier.high-end": "High-End",
"settings.advancedAi.ollamaPull.tier.mid-range": "Mittelklasse",
"settings.advancedAi.ollamaPull.tier.low-end": "Einsteiger",
"settings.advancedAi.ragModeHint": "Der Hybrid-Modus verbindet Keyword-Suche mit semantischem Verständnis — ideal für die meisten Geschichten. Index nach großen Änderungen neu aufbauen.",
"settings.advancedAi.ragModeHybrid": "Hybrid (semantisch + lexikalisch, empfohlen)",
"settings.advancedAi.ragModeLabel": "Kontext-Abrufmodus",
Expand Down
10 changes: 10 additions & 0 deletions locales/el/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,16 @@
"settings.advancedAi.model": "Μοντέλο AI",
"settings.advancedAi.modelDescription": "Το μοντέλο AI χρησιμοποιείται για τη δημιουργία σκηνής, την ανάλυση και άλλα χαρακτηριστικά που τροφοδοτούνται από AI. Η διαθεσιμότητα του μοντέλου εξαρτάται από τον πάροχο που έχετε επιλέξει.",
"settings.advancedAi.modelRecommendationsIntro": "Δημοφιλή τοπικά σημεία ελέγχου (επαληθεύστε τα ονόματα στο χρόνο εκτέλεσης):",
"settings.advancedAi.ollamaPull.recommendedFor": "Προτείνεται για τη συσκευή σας:",
"settings.advancedAi.ollamaPull.pull": "Λήψη μοντέλου",
"settings.advancedAi.ollamaPull.pulling": "Λήψη μοντέλου…",
"settings.advancedAi.ollamaPull.retry": "Επανάληψη",
"settings.advancedAi.ollamaPull.installed": "Εγκαταστάθηκε",
"settings.advancedAi.ollamaPull.useModel": "Χρήση αυτού του μοντέλου",
"settings.advancedAi.ollamaPull.batteryNote": "Μειώθηκε ένα μέγεθος για εξοικονόμηση μπαταρίας.",
"settings.advancedAi.ollamaPull.tier.high-end": "Υψηλών επιδόσεων",
"settings.advancedAi.ollamaPull.tier.mid-range": "Μεσαία",
"settings.advancedAi.ollamaPull.tier.low-end": "Βασική",
"settings.advancedAi.ragModeHint": "Η υβριδική λειτουργία συνδυάζει την αντιστοίχιση λέξεων-κλειδιών με τη σημασιολογική κατανόηση — το καλύτερο για τις περισσότερες ιστορίες. Δημιουργήστε ξανά το ευρετήριο μετά από μεγάλες επεξεργασίες.",
"settings.advancedAi.ragModeHybrid": "Υβριδικό (σημασιολογικό + λεξιλογικό, συνιστώμενο)",
"settings.advancedAi.ragModeLabel": "Λειτουργία ανάκτησης περιβάλλοντος",
Expand Down
10 changes: 10 additions & 0 deletions locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,16 @@
"settings.advancedAi.model": "AI Model",
"settings.advancedAi.modelDescription": "The AI model used for scene generation, analysis, and other AI-powered features. Model availability depends on your selected provider.",
"settings.advancedAi.modelRecommendationsIntro": "Popular local checkpoints (verify names in your runtime):",
"settings.advancedAi.ollamaPull.recommendedFor": "Recommended for your device:",
"settings.advancedAi.ollamaPull.pull": "Pull model",
"settings.advancedAi.ollamaPull.pulling": "Downloading model…",
"settings.advancedAi.ollamaPull.retry": "Retry",
"settings.advancedAi.ollamaPull.installed": "Installed",
"settings.advancedAi.ollamaPull.useModel": "Use this model",
"settings.advancedAi.ollamaPull.batteryNote": "Stepped down a size to save battery.",
"settings.advancedAi.ollamaPull.tier.high-end": "High-end",
"settings.advancedAi.ollamaPull.tier.mid-range": "Mid-range",
"settings.advancedAi.ollamaPull.tier.low-end": "Low-end",
"settings.advancedAi.ragModeHint": "Hybrid mode blends keyword matching with semantic understanding — best for most stories. Rebuild the index after large edits.",
"settings.advancedAi.ragModeHybrid": "Hybrid (semantic + lexical, recommended)",
"settings.advancedAi.ragModeLabel": "Context retrieval mode",
Expand Down
10 changes: 10 additions & 0 deletions locales/es/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,16 @@
"settings.advancedAi.model": "Modelo de IA",
"settings.advancedAi.modelDescription": "El modelo de IA usado para generación de escenas, análisis y otras funciones. La disponibilidad depende del proveedor seleccionado.",
"settings.advancedAi.modelRecommendationsIntro": "Puntos de control locales populares (verifica los nombres en tu entorno de ejecución):",
"settings.advancedAi.ollamaPull.recommendedFor": "Recomendado para tu dispositivo:",
"settings.advancedAi.ollamaPull.pull": "Descargar modelo",
"settings.advancedAi.ollamaPull.pulling": "Descargando modelo…",
"settings.advancedAi.ollamaPull.retry": "Reintentar",
"settings.advancedAi.ollamaPull.installed": "Instalado",
"settings.advancedAi.ollamaPull.useModel": "Usar este modelo",
"settings.advancedAi.ollamaPull.batteryNote": "Se eligió un tamaño menor para ahorrar batería.",
"settings.advancedAi.ollamaPull.tier.high-end": "Gama alta",
"settings.advancedAi.ollamaPull.tier.mid-range": "Gama media",
"settings.advancedAi.ollamaPull.tier.low-end": "Gama baja",
"settings.advancedAi.ragModeHint": "El modo híbrido combina búsqueda por palabras clave con comprensión semántica — ideal para la mayoría de historias. Reconstruye el índice tras grandes cambios.",
"settings.advancedAi.ragModeHybrid": "Híbrido (semántico + léxico, recomendado)",
"settings.advancedAi.ragModeLabel": "Modo de recuperación de contexto",
Expand Down
10 changes: 10 additions & 0 deletions locales/eu/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,16 @@
"settings.advancedAi.model": "AI eredua",
"settings.advancedAi.modelDescription": "Eszena sortzeko, analisirako eta AI bidezko beste funtzio batzuetarako erabiltzen den AI eredua. Ereduaren erabilgarritasuna hautatutako hornitzailearen araberakoa da.",
"settings.advancedAi.modelRecommendationsIntro": "Tokiko kontrol-puntu ezagunak (egiaztatu izenak zure exekuzioan):",
"settings.advancedAi.ollamaPull.recommendedFor": "Zure gailurako gomendatua:",
"settings.advancedAi.ollamaPull.pull": "Deskargatu eredua",
"settings.advancedAi.ollamaPull.pulling": "Eredua deskargatzen…",
"settings.advancedAi.ollamaPull.retry": "Saiatu berriro",
"settings.advancedAi.ollamaPull.installed": "Instalatuta",
"settings.advancedAi.ollamaPull.useModel": "Erabili eredu hau",
"settings.advancedAi.ollamaPull.batteryNote": "Tamaina bat jaitsi da bateria aurrezteko.",
"settings.advancedAi.ollamaPull.tier.high-end": "Goi-mailakoa",
"settings.advancedAi.ollamaPull.tier.mid-range": "Erdi-mailakoa",
"settings.advancedAi.ollamaPull.tier.low-end": "Oinarrizkoa",
"settings.advancedAi.ragModeHint": "Modu hibridoak gako-hitzekin bat etortzea ulermen semantikoarekin nahasten du, istorio gehienetarako onena. Berreraiki indizea aldaketa handien ondoren.",
"settings.advancedAi.ragModeHybrid": "Hibridoa (semantikoa + lexikoa, gomendagarria)",
"settings.advancedAi.ragModeLabel": "Testuingurua berreskuratzeko modua",
Expand Down
10 changes: 10 additions & 0 deletions locales/fa/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,16 @@
"settings.advancedAi.model": "مدل هوش مصنوعی",
"settings.advancedAi.modelDescription": "مدل هوش مصنوعی برای تولید صحنه، تحلیل و سایر ویژگی‌های مبتنی بر هوش مصنوعی استفاده می‌شود. در دسترس بودن مدل به ارائه دهنده انتخابی شما بستگی دارد.",
"settings.advancedAi.modelRecommendationsIntro": "نقاط بازرسی محلی محبوب (اسامی را در زمان اجرا تأیید کنید):",
"settings.advancedAi.ollamaPull.recommendedFor": "پیشنهادشده برای دستگاه شما:",
"settings.advancedAi.ollamaPull.pull": "دریافت مدل",
"settings.advancedAi.ollamaPull.pulling": "در حال دانلود مدل…",
"settings.advancedAi.ollamaPull.retry": "تلاش دوباره",
"settings.advancedAi.ollamaPull.installed": "نصب‌شده",
"settings.advancedAi.ollamaPull.useModel": "استفاده از این مدل",
"settings.advancedAi.ollamaPull.batteryNote": "برای صرفه‌جویی در باتری یک اندازه کوچک‌تر شد.",
"settings.advancedAi.ollamaPull.tier.high-end": "رده‌بالا",
"settings.advancedAi.ollamaPull.tier.mid-range": "میان‌رده",
"settings.advancedAi.ollamaPull.tier.low-end": "رده‌پایین",
"settings.advancedAi.ragModeHint": "حالت ترکیبی تطبیق کلمات کلیدی را با درک معنایی ترکیب می‌کند - بهترین برای اکثر داستان‌ها. پس از ویرایش های بزرگ، فهرست را دوباره بسازید.",
"settings.advancedAi.ragModeHybrid": "ترکیبی (معنی + واژگانی، توصیه شده)",
"settings.advancedAi.ragModeLabel": "حالت بازیابی متن",
Expand Down
Loading
Loading