From 33324587083257590476fede899113d3441efd69 Mon Sep 17 00:00:00 2001 From: yilsem Date: Mon, 7 Sep 2026 21:46:57 +0300 Subject: [PATCH] feat(desktop): open read-only TurkServis combo details in browser pane --- apps/desktop/package.json | 2 +- .../src/app/shell/model-catalog-menu.test.tsx | 18 +++++++++++++ .../src/app/shell/model-catalog-menu.tsx | 8 ++++++ .../src/app/shell/model-edit-submenu.test.tsx | 15 +++++++++++ .../src/app/shell/model-edit-submenu.tsx | 15 +++++++++-- apps/desktop/src/i18n/ar.ts | 1 + apps/desktop/src/i18n/en.ts | 1 + apps/desktop/src/i18n/ja.ts | 1 + apps/desktop/src/i18n/ru.ts | 1 + apps/desktop/src/i18n/tr.ts | 1 + apps/desktop/src/i18n/types.ts | 1 + apps/desktop/src/i18n/zh-hant.ts | 1 + apps/desktop/src/i18n/zh.ts | 1 + .../src/lib/turkservis-combo-details.test.ts | 25 +++++++++++++++++++ .../src/lib/turkservis-combo-details.ts | 21 ++++++++++++++++ package-lock.json | 2 +- 16 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/src/lib/turkservis-combo-details.test.ts create mode 100644 apps/desktop/src/lib/turkservis-combo-details.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c596895..5eaa81a 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.9", + "version": "1.0.0-beta.10", "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 faa167a..4839404 100644 --- a/apps/desktop/src/app/shell/model-catalog-menu.test.tsx +++ b/apps/desktop/src/app/shell/model-catalog-menu.test.tsx @@ -12,6 +12,7 @@ import { setModelVisibilityOpen, setVisibleModels } from '@/store/model-visibility' +import { openPreview } from '@/store/preview' import type { LocalRuntimeJob } from '@/types/hermes' import { ModelCatalogMenu, type ModelMenuController } from './model-catalog-menu' @@ -24,6 +25,7 @@ beforeAll(() => { }) const getGlobalModelOptions = vi.fn() +vi.mock('@/store/preview', () => ({ openPreview: vi.fn() })) vi.mock('@/hermes', () => ({ getGlobalModelOptions: (...args: unknown[]) => getGlobalModelOptions(...args), @@ -88,6 +90,22 @@ function renderMenu() { // the kanban board would end up disagreeing about what "my models" means — // which is exactly the drift extracting this component was meant to prevent. describe('the catalog owns model curation', () => { + it('opens the chosen TurkServis model details without selecting that model', async () => { + getGlobalModelOptions.mockResolvedValue({ providers: [{ + models: ['Outer'], name: 'TurkServis', slug: 'turkservis', api_url: 'https://ai.turkservis.online/v1' + }] }) + const select = renderMenu() + const trigger = await screen.findByRole('menuitem', { name: /Outer/ }) + + fireEvent.keyDown(trigger, { key: 'ArrowRight' }) + fireEvent.click(await screen.findByRole('menuitem', { name: /Model \/ combo (details|ayrıntıları)/ })) + + expect(openPreview).toHaveBeenCalledWith(expect.objectContaining({ + kind: 'url', url: 'https://turkservis.online/router?model=Outer' + })) + expect(select).not.toHaveBeenCalled() + }) + it('honours the stored Edit Models shortlist', async () => { setVisibleModels(new Set([modelVisibilityKey('google', 'gemini-2.5-flash')])) diff --git a/apps/desktop/src/app/shell/model-catalog-menu.tsx b/apps/desktop/src/app/shell/model-catalog-menu.tsx index 16c9c85..338ab2f 100644 --- a/apps/desktop/src/app/shell/model-catalog-menu.tsx +++ b/apps/desktop/src/app/shell/model-catalog-menu.tsx @@ -25,6 +25,7 @@ import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options' import { displayModelName, modelDisplayParts } from '@/lib/model-status-label' import { DEFAULT_REASONING_EFFORT, reasoningEffortLabel } from '@/lib/reasoning-effort' import { normalize } from '@/lib/text' +import { turkservisComboDetailsUrl } from '@/lib/turkservis-combo-details' import { useStoreSelector } from '@/lib/use-session-slice' import { cn } from '@/lib/utils' import { $localModelsEnabled } from '@/store/local-models-flag' @@ -38,6 +39,7 @@ import { modelVisibilityKey, setModelVisibilityOpen } from '@/store/model-visibility' +import { openPreview } from '@/store/preview' import { $collapsedProviders, toggleCollapsedProvider } from '@/store/provider-collapse' import { $defaultReasoningEffort } from '@/store/session' import type { LocalModelLoadProgress, ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes' @@ -588,6 +590,12 @@ export function ModelCatalogMenu({ fastControl={fastControl} isActive={isCurrent} model={family.id} + onOpenDetails={turkservisComboDetailsUrl(group.provider.api_url, family.id) ? () => { + const url = turkservisComboDetailsUrl(group.provider.api_url, family.id)! + + closeMenu() + openPreview({ kind: 'url', label: t.shell.modelOptions.comboDetails, source: url, url }) + } : undefined} onSelectModel={nextModel => controller.select(nextModel, group.provider.slug)} onSetOptions={patch => controller.setOptions(patch, { diff --git a/apps/desktop/src/app/shell/model-edit-submenu.test.tsx b/apps/desktop/src/app/shell/model-edit-submenu.test.tsx index 3685f8e..36b6d8f 100644 --- a/apps/desktop/src/app/shell/model-edit-submenu.test.tsx +++ b/apps/desktop/src/app/shell/model-edit-submenu.test.tsx @@ -29,6 +29,7 @@ function renderSubmenu(opts: { fastControl: FastControl isActive?: boolean onSelectModel?: (model: string) => void + onOpenDetails?: () => void onSetOptions: (patch: { effort?: string; fast?: boolean }) => void reasoning: boolean }) { @@ -43,6 +44,7 @@ function renderSubmenu(opts: { fastControl={opts.fastControl} isActive={opts.isActive ?? true} model="m1" + onOpenDetails={opts.onOpenDetails} onSelectModel={opts.onSelectModel ?? vi.fn()} onSetOptions={opts.onSetOptions} provider="p1" @@ -60,6 +62,19 @@ function renderSubmenu(opts: { // ever writes directly again, picking an effort for a kanban card would reach // over and change the user's live chat. describe('ModelEditSubmenu reports edits without performing them', () => { + it('opens read-only details without selecting a model or changing its options', () => { + const onOpenDetails = vi.fn() + const onSelectModel = vi.fn() + const onSetOptions = vi.fn() + + renderSubmenu({ fastControl: { kind: 'none' }, reasoning: false, onOpenDetails, onSelectModel, onSetOptions }) + fireEvent.click(screen.getByRole('menuitem', { name: /Model \/ combo (details|ayrıntıları)/ })) + + expect(onOpenDetails).toHaveBeenCalledOnce() + expect(onSelectModel).not.toHaveBeenCalled() + expect(onSetOptions).not.toHaveBeenCalled() + }) + it('param fast: reports the toggle', () => { const onSetOptions = vi.fn() renderSubmenu({ fastControl: { kind: 'param', on: true }, onSetOptions, reasoning: false }) diff --git a/apps/desktop/src/app/shell/model-edit-submenu.tsx b/apps/desktop/src/app/shell/model-edit-submenu.tsx index 68b7281..f3ecf24 100644 --- a/apps/desktop/src/app/shell/model-edit-submenu.tsx +++ b/apps/desktop/src/app/shell/model-edit-submenu.tsx @@ -75,6 +75,8 @@ interface ModelEditSubmenuProps { isActive: boolean /** This row's model id. */ model: string + /** Read-only details; the owner handles opening without changing selection. */ + onOpenDetails?: () => void /** Switch to a specific model id (used to swap base ⇄ -fast variant). */ onSelectModel: (model: string) => Promise | void /** Report an option change. This submenu is PURE: it never writes to a @@ -107,6 +109,7 @@ function ModelEditSubmenuBody({ effort, fastControl, isActive, + onOpenDetails, onSelectModel, onSetOptions, reasoning @@ -140,11 +143,19 @@ function ModelEditSubmenuBody({ const hasFast = fastControl.kind !== 'none' const fastOn = fastControl.kind === 'none' ? false : fastControl.on - return !hasFast && !reasoning ? ( + return !hasFast && !reasoning && !onOpenDetails ? (
{copy.noOptions}
) : ( <> - {copy.options} + {onOpenDetails ? ( + <> + + {copy.comboDetails} + + {hasFast || reasoning ? : null} + + ) : null} + {hasFast || reasoning ? {copy.options} : null} {showThinkingToggle ? ( event.preventDefault()}> {copy.thinking} diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index 295cc15..efad7d3 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -2235,6 +2235,7 @@ export const ar = defineLocale({ fast: 'سريع' }, modelOptions: { + comboDetails: 'تفاصيل النموذج / المجموعة', noOptions: 'لا توجد خيارات لهذا النموذج', options: 'الخيارات', thinking: 'التفكير', diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index d60df22..680f360 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -2999,6 +2999,7 @@ export const en: Translations = { fast: 'Fast' }, modelOptions: { + comboDetails: 'Model / combo details', noOptions: 'No options for this model', options: 'Options', thinking: 'Thinking', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 63008af..4de926b 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -2624,6 +2624,7 @@ export const ja = defineLocale({ fast: '高速' }, modelOptions: { + comboDetails: 'モデル / コンボの詳細', noOptions: 'このモデルにはオプションがありません', options: 'オプション', thinking: '思考', diff --git a/apps/desktop/src/i18n/ru.ts b/apps/desktop/src/i18n/ru.ts index 1f42e1e..1f8b59a 100644 --- a/apps/desktop/src/i18n/ru.ts +++ b/apps/desktop/src/i18n/ru.ts @@ -3022,6 +3022,7 @@ export const ru = defineLocale({ fast: 'Быстрая' }, modelOptions: { + comboDetails: 'Сведения о модели / комбинации', noOptions: 'Для этой модели нет опций', options: 'Опции', thinking: 'Размышление', diff --git a/apps/desktop/src/i18n/tr.ts b/apps/desktop/src/i18n/tr.ts index 1cc0ecb..995c2d8 100644 --- a/apps/desktop/src/i18n/tr.ts +++ b/apps/desktop/src/i18n/tr.ts @@ -1281,6 +1281,7 @@ export const trOverrides: TranslationOverrides = { fast: 'Hızlı' }, modelOptions: { + comboDetails: 'Model / combo ayrıntıları', noOptions: 'Bu model için seçenek yok', options: 'Seçenekler', thinking: 'Düşünme', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 9c6d6a2..0e67d7b 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -2546,6 +2546,7 @@ export interface Translations { fast: string } modelOptions: { + comboDetails: string noOptions: string options: string thinking: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 8e3dc7c..5da6b1b 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -2523,6 +2523,7 @@ export const zhHant = defineLocale({ fast: '快速' }, modelOptions: { + comboDetails: '模型 / 組合詳細資料', noOptions: '此模型沒有可用選項', options: '選項', thinking: '思考', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 256a940..d900e38 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -3147,6 +3147,7 @@ export const zh: Translations = { fast: '快速' }, modelOptions: { + comboDetails: '模型 / 组合详情', noOptions: '此模型没有可用选项', options: '选项', thinking: '思考', diff --git a/apps/desktop/src/lib/turkservis-combo-details.test.ts b/apps/desktop/src/lib/turkservis-combo-details.test.ts new file mode 100644 index 0000000..fea2fb6 --- /dev/null +++ b/apps/desktop/src/lib/turkservis-combo-details.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' + +import { turkservisComboDetailsUrl } from './turkservis-combo-details' + +describe('TurkServis read-only details link', () => { + it('preserves the exact model and drops API query parameters', () => { + const result = turkservisComboDetailsUrl('https://ai.turkservis.online/v1?key=private', '9router-Team & A')! + + expect(new URL(result).searchParams.get('model')).toBe('9router-Team & A') + expect(new URL(result).origin).toBe('https://turkservis.online') + expect(result).not.toContain('private') + }) + + it.each([undefined, '', 'invalid', 'http://ai.turkservis.online/v1', + 'https://ai.turkservis.online.evil.test/v1', 'https://other.test/v1', + 'https://user:secret@ai.turkservis.online/v1', 'https://ai.turkservis.online:9090/v1' + ])('does not offer details for an unrelated or unsafe endpoint: %s', apiUrl => { + expect(turkservisComboDetailsUrl(apiUrl, 'Team')).toBeNull() + }) + + it('rejects missing and oversized model identities', () => { + expect(turkservisComboDetailsUrl('https://turkservis.online/v1', '')).toBeNull() + expect(turkservisComboDetailsUrl('https://turkservis.online/v1', 'x'.repeat(129))).toBeNull() + }) +}) diff --git a/apps/desktop/src/lib/turkservis-combo-details.ts b/apps/desktop/src/lib/turkservis-combo-details.ts new file mode 100644 index 0000000..b2900ff --- /dev/null +++ b/apps/desktop/src/lib/turkservis-combo-details.ts @@ -0,0 +1,21 @@ +/** Details use the member portal's own login; API credentials never enter a URL. */ +export function turkservisComboDetailsUrl(apiUrl: string | undefined, model: string): string | null { + if (!apiUrl || !model || model.length > 128) {return null} + + try { + const endpoint = new URL(apiUrl) + + if ( + endpoint.protocol !== 'https:' || endpoint.username || endpoint.password || endpoint.port || + !['ai.turkservis.online', 'turkservis.online'].includes(endpoint.hostname) + ) {return null} + + const details = new URL('https://turkservis.online/router') + + details.searchParams.set('model', model) + + return details.href + } catch { + return null + } +} diff --git a/package-lock.json b/package-lock.json index 18f7b25..da3836c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,7 +65,7 @@ }, "apps/desktop": { "name": "aiturk-ide", - "version": "1.0.0-beta.9", + "version": "1.0.0-beta.10", "license": "MIT", "dependencies": { "@assistant-ui/core": "0.2.23",