diff --git a/README.AITURK.md b/README.AITURK.md index 5711f70..f377fc7 100644 --- a/README.AITURK.md +++ b/README.AITURK.md @@ -2,7 +2,7 @@ TurkServis'in Hermes Agent tabanlı masaüstü geliştirme ortamı. -- Ürün sürümü: **1.0.0-beta.1**, ilk paket hedefi Windows x64. +- Ürün sürümü: **1.0.0-beta.4**, ilk paket hedefi Windows x64. - Varsayılan dil Türkçe; ayarlardan diğer dillere geçilebilir. - İlk kurulum, sohbet, dosyalar, terminal ve temel ayarlar Türkçeleştirilmiştir. Henüz çevrilmeyen gelişmiş ekranlar ve üçüncü taraf eklentiler İngilizceye döner. @@ -10,6 +10,9 @@ TurkServis'in Hermes Agent tabanlı masaüstü geliştirme ortamı. - TurkServis bağlantısı `https://ai.turkservis.online/v1` üzerinden kullanıcının kendi API anahtarını doğrular ve hesabına açık modelleri dinamik olarak getirir. Kaynakta veya kurulum paketinde müşteri API anahtarı bulunmaz. +- AITURK model kataloğu arka planda otomatik yenilenir. Açık model listeleri + dakikada bir sunucuyu kontrol eder; 9Router ve OmniRoute'ta yeni yayımlanan + kayıtlar elle liste düzenlemeden görünür. Admin engeli sunucuda uygulanır. - Bu bağlantı model sağlayıcısıdır; yerel ajan ve dosya araçları bilgisayarda çalışır. Uzak Hermes gateway bağlantısı farklı bir özelliktir. diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 857a6c5..34a0302 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -2,7 +2,7 @@ "name": "aiturk-ide", "productName": "AITURK IDE", "private": true, - "version": "1.0.0-beta.3", + "version": "1.0.0-beta.4", "description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.", "author": "AITURK / TurkServis", "repository": { diff --git a/apps/desktop/src/app/shell/model-catalog-menu.test.tsx b/apps/desktop/src/app/shell/model-catalog-menu.test.tsx index ae42a4a..faa167a 100644 --- a/apps/desktop/src/app/shell/model-catalog-menu.test.tsx +++ b/apps/desktop/src/app/shell/model-catalog-menu.test.tsx @@ -103,7 +103,7 @@ describe('the catalog owns model curation', () => { renderMenu() await screen.findByText(/Gemini 2\.5 Flash/i) - const input = screen.getByRole('textbox', { name: 'Search models' }) + const input = screen.getByRole('textbox', { name: /Search models|Model ara/ }) fireEvent.change(input, { target: { value: 'gemini-3.1' } }) @@ -116,7 +116,7 @@ describe('the catalog owns model curation', () => { renderMenu() await screen.findByText(/Gemini 3\.1 Pro/i) - fireEvent.click(screen.getByText('Edit models…')) + fireEvent.click(screen.getByText(/Edit models…|Modelleri düzenle…/)) expect($modelVisibilityOpen.get()).toBe(true) }) diff --git a/apps/desktop/src/lib/query-client.test.ts b/apps/desktop/src/lib/query-client.test.ts index c2f0cc2..accc18a 100644 --- a/apps/desktop/src/lib/query-client.test.ts +++ b/apps/desktop/src/lib/query-client.test.ts @@ -1,4 +1,5 @@ -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { QueryObserver, focusManager } from '@tanstack/react-query' import { invalidateProfileScopedQueries, queryClient } from './query-client' @@ -11,6 +12,29 @@ describe('invalidateProfileScopedQueries', () => { queryClient.clear() }) + it('refreshes an observed model list without reopening the picker', async () => { + vi.useFakeTimers() + focusManager.setFocused(true) + let models = ['old-model'] + const observer = new QueryObserver(queryClient, { + queryKey: ['model-options', 'selected-profile'], + queryFn: async () => models + }) + const unsubscribe = observer.subscribe(() => {}) + try { + await observer.refetch() + expect(observer.getCurrentResult().data).toEqual(['old-model']) + models = ['new-model', 'new-combo'] + await vi.advanceTimersByTimeAsync(60_000) + expect(observer.getCurrentResult().data).toEqual(models) + } finally { + unsubscribe() + queryClient.clear() + focusManager.setFocused(undefined) + vi.useRealTimers() + } + }) + it('invalidates profile-scoped caches and leaves account/global caches intact', () => { const profileScoped = [ ['hermes-config-record'], diff --git a/apps/desktop/src/lib/query-client.ts b/apps/desktop/src/lib/query-client.ts index cfea4f1..67918c4 100644 --- a/apps/desktop/src/lib/query-client.ts +++ b/apps/desktop/src/lib/query-client.ts @@ -12,6 +12,12 @@ export const queryClient = new QueryClient({ } }) +queryClient.setQueryDefaults(['model-options'], { + staleTime: 30_000, + refetchInterval: 60_000, + refetchOnWindowFocus: true +}) + // Curried, setState-shaped cache writer for optimistic write-through: keeps // mutation sites terse (`setX(next)` or `setX(prev => …)`) over one query key. export const writeCache = diff --git a/hermes_cli/models.py b/hermes_cli/models.py index 4207e96..d701791 100644 --- a/hermes_cli/models.py +++ b/hermes_cli/models.py @@ -8,6 +8,7 @@ Add, remove, or reorder entries here — both `hermes setup` and from __future__ import annotations import copy +import contextvars import json import http.client import logging @@ -4809,10 +4810,11 @@ def _spawn_swr_refresh(cache_key: str, refresh_fn=None) -> None: ``PROVIDER_REGISTRY`` slug and refreshed via :func:`provider_model_ids` (the original behavior). """ + refresh_identity = (str(_provider_models_cache_path()), cache_key) with _swr_refresh_lock: - if cache_key in _swr_refresh_inflight: + if refresh_identity in _swr_refresh_inflight: return - _swr_refresh_inflight.add(cache_key) + _swr_refresh_inflight.add(refresh_identity) def _default_refresh(): live = provider_model_ids(cache_key, force_refresh=True) @@ -4847,10 +4849,10 @@ def _spawn_swr_refresh(cache_key: str, refresh_fn=None) -> None: logger.debug("SWR refresh failed for %s", cache_key, exc_info=True) finally: with _swr_refresh_lock: - _swr_refresh_inflight.discard(cache_key) + _swr_refresh_inflight.discard(refresh_identity) threading.Thread( - target=_refresh, daemon=True, name=f"model-cache-swr-{cache_key}" + target=contextvars.copy_context().run, args=(_refresh,), daemon=True, name=f"model-cache-swr-{cache_key}" ).start() @@ -6855,6 +6857,17 @@ def cached_fetch_api_models( entry = cache.get(cache_key) now = time.time() + if normalized_url == "https://ai.turkservis.online/v1": + ttl_seconds = min(ttl_seconds, 30) + if cache_only and (not _cache_entry_valid(entry, fp) or now - entry["at"] >= ttl_seconds): + def _refresh_aiturk(): + live = fetch_api_models(api_key, base_url, timeout=timeout, + api_mode=api_mode, headers=headers) + if live is None: + return None + return {"fp": fp, "at": time.time(), "models": list(live)} + _spawn_swr_refresh(cache_key, _refresh_aiturk) + if cache_only: # Same trust window as the stale-while-revalidate tier below, minus # the revalidation: an entry this side of the bound is good enough to diff --git a/package-lock.json b/package-lock.json index 25cc3b9..4317334 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,7 +65,7 @@ }, "apps/desktop": { "name": "aiturk-ide", - "version": "1.0.0-beta.3", + "version": "1.0.0-beta.4", "license": "MIT", "dependencies": { "@assistant-ui/core": "0.2.23", diff --git a/tests/hermes_cli/test_aiturk_catalog_refresh.py b/tests/hermes_cli/test_aiturk_catalog_refresh.py new file mode 100644 index 0000000..3328e11 --- /dev/null +++ b/tests/hermes_cli/test_aiturk_catalog_refresh.py @@ -0,0 +1,55 @@ +"""An open AITURK picker discovers changes without an explicit refresh.""" +import threading +import time + +from hermes_cli import models + + +def test_cache_only_picker_refreshes_stale_aiturk_catalog(tmp_path, monkeypatch): + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + url = "https://ai.turkservis.online/v1" + key = "test-only" + fp = models._custom_endpoint_fingerprint(key, None, None) + models._save_provider_models_cache({"custom:" + url: { + "fp": fp, "at": time.time() - 90, "models": ["old"]}}) + started = threading.Event() + release = threading.Event() + + def fetch(*args, **kwargs): + started.set() + assert release.wait(5) + return ["new-model", "new-combo"] + + monkeypatch.setattr(models, "fetch_api_models", fetch) + try: + assert models.cached_fetch_api_models(key, url, cache_only=True) == ["old"] + assert started.wait(5) + finally: + release.set() + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + result = models.cached_fetch_api_models(key, url, cache_only=True) + if result == ["new-model", "new-combo"]: + break + time.sleep(0.02) + assert result == ["new-model", "new-combo"] + + +def test_background_refresh_preserves_context_scope(tmp_path, monkeypatch): + import contextvars + scope = contextvars.ContextVar("test_catalog_scope", default="wrong") + seen = [] + done = threading.Event() + monkeypatch.setattr(models, "_provider_models_cache_path", lambda: tmp_path / scope.get()) + monkeypatch.setattr(models, "_load_provider_models_cache", lambda: {}) + def save(cache): + seen.append((scope.get(), cache)) + done.set() + monkeypatch.setattr(models, "_save_provider_models_cache", save) + token = scope.set("selected-profile") + try: + models._spawn_swr_refresh("scope-test", lambda: {"models": [scope.get()]}) + finally: + scope.reset(token) + assert done.wait(5) + assert seen == [("selected-profile", {"scope-test": {"models": ["selected-profile"]}})]