diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index f5ee2ca..f9da359 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -8,7 +8,12 @@ import { translateNow } from '@/i18n' import { desktopDefaultCwd } from '@/lib/desktop-fs' import { decideLivenessForceClose, LIVENESS_REPROBE_DELAY_MS } from '@/lib/gateway-liveness-policy' import { reconnectBackoffDelayMs } from '@/lib/reconnect-backoff' -import { BACKEND_BOOT_WAIT_TIMEOUT_MS, RECONNECT_ATTEMPT_TIMEOUT_MS, withTimeout } from '@/lib/with-timeout' +import { + BACKEND_BOOT_WAIT_TIMEOUT_MS, + RECONNECT_ATTEMPT_TIMEOUT_MS, + withBootstrapAwareTimeout, + withTimeout +} from '@/lib/with-timeout' import { $desktopBoot, applyDesktopBootProgress, @@ -988,10 +993,12 @@ export function useGatewayBoot({ // round-trip must not hang "Starting Hermes…" forever. Initial boot // rides out a full backend cold spawn, so it gets the shared 45s // backend-boot budget, not the 20s reconnect budget. - const conn = await withTimeout( + const conn = await withBootstrapAwareTimeout( desktop.getConnection(windowProfileOverride() ?? undefined), BACKEND_BOOT_WAIT_TIMEOUT_MS, - 'Timed out connecting to Hermes backend' + 'Timed out connecting to Hermes backend', + desktop.getBootstrapState ? () => desktop.getBootstrapState!() : undefined, + () => cancelled ) if (cancelled) { diff --git a/apps/desktop/src/components/desktop-install-overlay.tsx b/apps/desktop/src/components/desktop-install-overlay.tsx index 4f81483..442e199 100644 --- a/apps/desktop/src/components/desktop-install-overlay.tsx +++ b/apps/desktop/src/components/desktop-install-overlay.tsx @@ -15,6 +15,7 @@ import type { DesktopBootstrapState } from '@/global' import { useI18n } from '@/i18n' +import { aiturkInstallStage } from '@/i18n/aiturk' import { AlertCircle, ChevronDown, ChevronRight, Globe, iconSize, Loader2, Monitor } from '@/lib/icons' import { capitalize } from '@/lib/text' import { cn } from '@/lib/utils' @@ -59,6 +60,10 @@ interface StageRowProps { } function formatStageName(name: string): string { + const translated = aiturkInstallStage(name) + if (translated) { + return translated + } // 'system-packages' -> 'System packages'; 'uv' stays 'uv' if (name.length <= 3) { return name diff --git a/apps/desktop/src/components/onboarding/index.tsx b/apps/desktop/src/components/onboarding/index.tsx index 9810a00..8edfbb2 100644 --- a/apps/desktop/src/components/onboarding/index.tsx +++ b/apps/desktop/src/components/onboarding/index.tsx @@ -270,6 +270,12 @@ export function DesktopOnboardingOverlay({ // do we know whether to dismiss (true) or surface the picker (false). // EXCEPTION: manual mode (user opened the selector from a working app to // add/switch a provider) shows the overlay regardless of configured state. + // The local installer owns first-run choice and download progress. Provider + // onboarding becomes meaningful after that runtime is available. + if (boot.running && boot.phase === 'bootstrap.choice' && !onboarding.manual) { + return null + } + if (onboarding.configured === true && !onboarding.manual) { return null } @@ -446,7 +452,13 @@ export function Picker({ ctx }: { ctx: OnboardingContext }) { const ordered = useMemo(() => (providers ? sortProviders(providers) : []), [providers]) const hasOauth = ordered.length > 0 const apiKeyOptions = useApiKeyCatalog() - const aiturkProvider = { await refreshOnboarding(ctx) }} /> + const aiturkProvider = ( + { + await refreshOnboarding(ctx) + }} + /> + ) // localEndpoint forces the key form regardless of `mode` (which a manual // provider refresh may flip back to 'oauth'); it preselects the local option @@ -674,7 +686,9 @@ export function ApiKeyForm({ type="button" >
- {o.id === 'local' ? t.settings.providers.localEndpoint.title : o.name} + + {o.id === 'local' ? t.settings.providers.localEndpoint.title : o.name} + {isSet?.(o.envKey) ? : null}
{(t.onboarding.apiKeyOptions[o.id]?.short ?? o.short) ? ( diff --git a/apps/desktop/src/i18n/aiturk.ts b/apps/desktop/src/i18n/aiturk.ts index 59ee80c..0ee6a22 100644 --- a/apps/desktop/src/i18n/aiturk.ts +++ b/apps/desktop/src/i18n/aiturk.ts @@ -1,45 +1,124 @@ import { useI18n } from './context' +import { getRuntimeI18nLocale } from './runtime' + +const trInstallStages: Record = { + uv: 'uv', + git: 'Git', + node: 'Node.js', + 'system-packages': 'Sistem araçları', + repository: 'Kaynak kodu', + python: 'Python', + venv: 'Python ortamı', + dependencies: 'Ajan bağımlılıkları', + 'node-deps': 'Node.js bağımlılıkları', + path: 'Komut yolları', + 'config-templates': 'Ayar şablonları', + 'platform-sdks': 'Platform araçları', + 'bootstrap-marker': 'Kurulum doğrulaması', + configure: 'Yapılandırma', + gateway: 'Ağ geçidi' +} + +export function aiturkInstallStage(name: string): string | undefined { + return getRuntimeI18nLocale() === 'tr' ? trInstallStages[name] : undefined +} const en = { intro: 'Bring your code, question, or idea. Build with AITURK.', - pitch: 'Connect your models with your TurkServis account.', key: 'TurkServis API key', - getKey: 'My account and API keys', noModels: 'No models are available for this key.', + pitch: 'Connect your models with your TurkServis account.', + key: 'TurkServis API key', + getKey: 'My account and API keys', + noModels: 'No models are available for this key.', failed: 'Could not connect to TurkServis.', custom: { - title: 'Custom endpoints', active: 'Active', keySet: 'API key set', use: 'Use', delete: 'Delete endpoint', - empty: 'No custom endpoints', emptyDesc: 'Add an OpenAI-compatible endpoint below.', edit: 'Edit endpoint', add: 'Add endpoint', - name: 'Name', providerId: 'Provider ID', url: 'Endpoint URL', model: 'Default model', context: 'Context', auto: 'Auto', - apiKey: 'API key', keepKey: 'Leave blank to keep current key', optional: 'Optional', newChats: 'Use for new chats', - discover: 'Discover models', test: 'Test', newEndpoint: 'New endpoint', loadFailed: 'Could not load custom endpoints', - saved: 'Custom endpoint saved.', saveFailed: 'Save failed', reachable: 'Endpoint is reachable.', validationFailed: 'Endpoint validation failed.', - activationFailed: 'Activation failed', deleteFailed: 'Delete failed', preset: 'Use TurkServis settings', - found: (n: number) => `Endpoint is reachable. Found ${n} models.`, confirmDelete: (name: string) => `Delete ${name}?` + title: 'Custom endpoints', + active: 'Active', + keySet: 'API key set', + use: 'Use', + delete: 'Delete endpoint', + empty: 'No custom endpoints', + emptyDesc: 'Add an OpenAI-compatible endpoint below.', + edit: 'Edit endpoint', + add: 'Add endpoint', + name: 'Name', + providerId: 'Provider ID', + url: 'Endpoint URL', + model: 'Default model', + context: 'Context', + auto: 'Auto', + apiKey: 'API key', + keepKey: 'Leave blank to keep current key', + optional: 'Optional', + newChats: 'Use for new chats', + discover: 'Discover models', + test: 'Test', + newEndpoint: 'New endpoint', + loadFailed: 'Could not load custom endpoints', + saved: 'Custom endpoint saved.', + saveFailed: 'Save failed', + reachable: 'Endpoint is reachable.', + validationFailed: 'Endpoint validation failed.', + activationFailed: 'Activation failed', + deleteFailed: 'Delete failed', + preset: 'Use TurkServis settings', + found: (n: number) => `Endpoint is reachable. Found ${n} models.`, + confirmDelete: (name: string) => `Delete ${name}?` }, about: 'TurkServis distribution based on Hermes Agent. Upstream: Nous Research, MIT license.', - downloads: 'Official downloads and updates', source: 'Source code and license', + downloads: 'Official downloads and updates', + source: 'Source code and license', uninstallTitle: 'Uninstall AITURK IDE', - uninstallDescription: 'Use your operating system’s application manager to uninstall AITURK IDE. Your separate agent data folder is preserved.' + uninstallDescription: + 'Use your operating system’s application manager to uninstall AITURK IDE. Your separate agent data folder is preserved.' } const tr: typeof en = { intro: 'Kodunuzu, sorunuzu veya fikrinizi paylaşın. AITURK ile birlikte geliştirin.', - pitch: 'TurkServis hesabınızla modellerinize bağlanın.', key: 'TurkServis API anahtarı', - getKey: 'Hesabım ve API anahtarlarım', noModels: 'Bu anahtarla kullanılabilecek model bulunamadı.', + pitch: 'TurkServis hesabınızla modellerinize bağlanın.', + key: 'TurkServis API anahtarı', + getKey: 'Hesabım ve API anahtarlarım', + noModels: 'Bu anahtarla kullanılabilecek model bulunamadı.', failed: 'TurkServis bağlantısı kurulamadı.', custom: { - title: 'Özel API bağlantıları', active: 'Etkin', keySet: 'API anahtarı ayarlandı', use: 'Kullan', delete: 'Bağlantıyı sil', - empty: 'Özel bağlantı yok', emptyDesc: 'Aşağıdan OpenAI uyumlu bir API bağlantısı ekleyin.', edit: 'Bağlantıyı düzenle', add: 'Bağlantı ekle', - name: 'Ad', providerId: 'Sağlayıcı kimliği', url: 'API adresi', model: 'Varsayılan model', context: 'Bağlam', auto: 'Otomatik', - apiKey: 'API anahtarı', keepKey: 'Mevcut anahtarı korumak için boş bırakın', optional: 'İsteğe bağlı', newChats: 'Yeni sohbetlerde kullan', - discover: 'Modelleri keşfet', test: 'Sına', newEndpoint: 'Yeni bağlantı', loadFailed: 'Özel bağlantılar yüklenemedi', - saved: 'Özel bağlantı kaydedildi.', saveFailed: 'Kaydedilemedi', reachable: 'API adresine erişiliyor.', validationFailed: 'Bağlantı doğrulanamadı.', - activationFailed: 'Etkinleştirilemedi', deleteFailed: 'Silinemedi', preset: 'TurkServis ayarlarını kullan', - found: n => `API adresine erişiliyor. ${n} model bulundu.`, confirmDelete: name => `${name} silinsin mi?` + title: 'Özel API bağlantıları', + active: 'Etkin', + keySet: 'API anahtarı ayarlandı', + use: 'Kullan', + delete: 'Bağlantıyı sil', + empty: 'Özel bağlantı yok', + emptyDesc: 'Aşağıdan OpenAI uyumlu bir API bağlantısı ekleyin.', + edit: 'Bağlantıyı düzenle', + add: 'Bağlantı ekle', + name: 'Ad', + providerId: 'Sağlayıcı kimliği', + url: 'API adresi', + model: 'Varsayılan model', + context: 'Bağlam', + auto: 'Otomatik', + apiKey: 'API anahtarı', + keepKey: 'Mevcut anahtarı korumak için boş bırakın', + optional: 'İsteğe bağlı', + newChats: 'Yeni sohbetlerde kullan', + discover: 'Modelleri keşfet', + test: 'Sına', + newEndpoint: 'Yeni bağlantı', + loadFailed: 'Özel bağlantılar yüklenemedi', + saved: 'Özel bağlantı kaydedildi.', + saveFailed: 'Kaydedilemedi', + reachable: 'API adresine erişiliyor.', + validationFailed: 'Bağlantı doğrulanamadı.', + activationFailed: 'Etkinleştirilemedi', + deleteFailed: 'Silinemedi', + preset: 'TurkServis ayarlarını kullan', + found: n => `API adresine erişiliyor. ${n} model bulundu.`, + confirmDelete: name => `${name} silinsin mi?` }, about: 'Hermes Agent tabanlı TurkServis dağıtımı. Kaynak: Nous Research, MIT lisansı.', - downloads: 'Resmî indirmeler ve güncellemeler', source: 'Kaynak kodu ve lisans', + downloads: 'Resmî indirmeler ve güncellemeler', + source: 'Kaynak kodu ve lisans', uninstallTitle: 'AITURK IDE’yi kaldır', - uninstallDescription: 'AITURK IDE’yi işletim sisteminin uygulama yöneticisinden kaldırın. Windows’ta Ayarlar → Uygulamalar → Yüklü uygulamalar yolunu izleyin. Ayrı ajan veri klasörünüz korunur.' + uninstallDescription: + 'AITURK IDE’yi işletim sisteminin uygulama yöneticisinden kaldırın. Windows’ta Ayarlar → Uygulamalar → Yüklü uygulamalar yolunu izleyin. Ayrı ajan veri klasörünüz korunur.' } export function useAiturkCopy() { diff --git a/apps/desktop/src/lib/with-timeout.test.ts b/apps/desktop/src/lib/with-timeout.test.ts index f756a2e..077ea02 100644 --- a/apps/desktop/src/lib/with-timeout.test.ts +++ b/apps/desktop/src/lib/with-timeout.test.ts @@ -1,8 +1,67 @@ import { describe, expect, it, vi } from 'vitest' -import { withTimeout } from './with-timeout' +import { withBootstrapAwareTimeout, withTimeout } from './with-timeout' describe('withTimeout', () => { + it.each([{ active: true }, { setupChoice: { active: true } }])( + 'waits beyond cold boot during setup: %j', + async state => { + vi.useFakeTimers() + try { + let finish!: (value: string) => void + const connection = new Promise(resolve => { + finish = resolve + }) + const probe = vi.fn(async () => state) + const result = withBootstrapAwareTimeout(connection, 45_000, 'timeout', probe) + await vi.advanceTimersByTimeAsync(180_000) + expect(probe).toHaveBeenCalledTimes(4) + finish('connected') + await expect(result).resolves.toBe('connected') + } finally { + vi.useRealTimers() + } + } + ) + + it('keeps an ordinary stalled backend bounded', async () => { + vi.useFakeTimers() + try { + const result = withBootstrapAwareTimeout(new Promise(() => {}), 45_000, 'timeout', async () => ({ + active: false + })) + const rejection = expect(result).rejects.toThrow('timeout') + await vi.advanceTimersByTimeAsync(45_000) + await rejection + } finally { + vi.useRealTimers() + } + }) + + it('bounds a stalled bootstrap IPC probe', async () => { + vi.useFakeTimers() + try { + const result = withBootstrapAwareTimeout( + new Promise(() => {}), + 45_000, + 'timeout', + () => new Promise(() => {}) + ) + const rejection = expect(result).rejects.toThrow('timeout') + await vi.advanceTimersByTimeAsync(50_000) + await rejection + } finally { + vi.useRealTimers() + } + }) + + it('propagates a real backend failure during setup', async () => { + const failure = new Error('runtime install failed') + await expect( + withBootstrapAwareTimeout(Promise.reject(failure), 45_000, 'timeout', async () => ({ active: true })) + ).rejects.toBe(failure) + }) + it('rejects with an onTimeout exception instead of letting it escape the timer callback', async () => { vi.useFakeTimers() diff --git a/apps/desktop/src/lib/with-timeout.ts b/apps/desktop/src/lib/with-timeout.ts index 3bcbf94..7f82ed6 100644 --- a/apps/desktop/src/lib/with-timeout.ts +++ b/apps/desktop/src/lib/with-timeout.ts @@ -28,6 +28,43 @@ export function isTimeoutError(error: unknown): error is TimeoutError { return error instanceof TimeoutError } +/** First-run downloads and the user's setup choice are outside the normal + * backend cold-start budget. Keep one connection request alive while main + * reports that work; a stalled IPC probe and ordinary boots remain bounded. */ +export async function withBootstrapAwareTimeout( + promise: Promise, + ms: number, + message: string, + bootstrapState?: () => Promise<{ active?: boolean; setupChoice?: unknown } | null>, + cancelled: () => boolean = () => false +): Promise { + let settled = false + const request = promise.then( + value => { + settled = true + return value + }, + error => { + settled = true + throw error + } + ) + + for (;;) { + try { + return await withTimeout(request, ms, message) + } catch (error) { + if (settled || cancelled() || !isTimeoutError(error) || !bootstrapState) { + throw error + } + const state = await withTimeout(bootstrapState(), Math.min(ms, 5_000), message) + if (!state?.active && !state?.setupChoice) { + throw error + } + } + } +} + /** Settle with `promise`, or reject with a TimeoutError after `ms`. * `onTimeout` runs synchronously before the rejection is published so callers * can revoke ownership of work that would otherwise keep running unowned. If