feat(desktop): open read-only TurkServis combo details in browser pane
This commit is contained in:
@@ -2,7 +2,7 @@
|
|||||||
"name": "aiturk-ide",
|
"name": "aiturk-ide",
|
||||||
"productName": "AITURK IDE",
|
"productName": "AITURK IDE",
|
||||||
"private": true,
|
"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.",
|
"description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.",
|
||||||
"author": "AITURK / TurkServis",
|
"author": "AITURK / TurkServis",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
setModelVisibilityOpen,
|
setModelVisibilityOpen,
|
||||||
setVisibleModels
|
setVisibleModels
|
||||||
} from '@/store/model-visibility'
|
} from '@/store/model-visibility'
|
||||||
|
import { openPreview } from '@/store/preview'
|
||||||
import type { LocalRuntimeJob } from '@/types/hermes'
|
import type { LocalRuntimeJob } from '@/types/hermes'
|
||||||
|
|
||||||
import { ModelCatalogMenu, type ModelMenuController } from './model-catalog-menu'
|
import { ModelCatalogMenu, type ModelMenuController } from './model-catalog-menu'
|
||||||
@@ -24,6 +25,7 @@ beforeAll(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const getGlobalModelOptions = vi.fn()
|
const getGlobalModelOptions = vi.fn()
|
||||||
|
vi.mock('@/store/preview', () => ({ openPreview: vi.fn() }))
|
||||||
|
|
||||||
vi.mock('@/hermes', () => ({
|
vi.mock('@/hermes', () => ({
|
||||||
getGlobalModelOptions: (...args: unknown[]) => getGlobalModelOptions(...args),
|
getGlobalModelOptions: (...args: unknown[]) => getGlobalModelOptions(...args),
|
||||||
@@ -88,6 +90,22 @@ function renderMenu() {
|
|||||||
// the kanban board would end up disagreeing about what "my models" means —
|
// the kanban board would end up disagreeing about what "my models" means —
|
||||||
// which is exactly the drift extracting this component was meant to prevent.
|
// which is exactly the drift extracting this component was meant to prevent.
|
||||||
describe('the catalog owns model curation', () => {
|
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 () => {
|
it('honours the stored Edit Models shortlist', async () => {
|
||||||
setVisibleModels(new Set([modelVisibilityKey('google', 'gemini-2.5-flash')]))
|
setVisibleModels(new Set([modelVisibilityKey('google', 'gemini-2.5-flash')]))
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options'
|
|||||||
import { displayModelName, modelDisplayParts } from '@/lib/model-status-label'
|
import { displayModelName, modelDisplayParts } from '@/lib/model-status-label'
|
||||||
import { DEFAULT_REASONING_EFFORT, reasoningEffortLabel } from '@/lib/reasoning-effort'
|
import { DEFAULT_REASONING_EFFORT, reasoningEffortLabel } from '@/lib/reasoning-effort'
|
||||||
import { normalize } from '@/lib/text'
|
import { normalize } from '@/lib/text'
|
||||||
|
import { turkservisComboDetailsUrl } from '@/lib/turkservis-combo-details'
|
||||||
import { useStoreSelector } from '@/lib/use-session-slice'
|
import { useStoreSelector } from '@/lib/use-session-slice'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { $localModelsEnabled } from '@/store/local-models-flag'
|
import { $localModelsEnabled } from '@/store/local-models-flag'
|
||||||
@@ -38,6 +39,7 @@ import {
|
|||||||
modelVisibilityKey,
|
modelVisibilityKey,
|
||||||
setModelVisibilityOpen
|
setModelVisibilityOpen
|
||||||
} from '@/store/model-visibility'
|
} from '@/store/model-visibility'
|
||||||
|
import { openPreview } from '@/store/preview'
|
||||||
import { $collapsedProviders, toggleCollapsedProvider } from '@/store/provider-collapse'
|
import { $collapsedProviders, toggleCollapsedProvider } from '@/store/provider-collapse'
|
||||||
import { $defaultReasoningEffort } from '@/store/session'
|
import { $defaultReasoningEffort } from '@/store/session'
|
||||||
import type { LocalModelLoadProgress, ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
|
import type { LocalModelLoadProgress, ModelOptionProvider, ModelOptionsResponse } from '@/types/hermes'
|
||||||
@@ -588,6 +590,12 @@ export function ModelCatalogMenu({
|
|||||||
fastControl={fastControl}
|
fastControl={fastControl}
|
||||||
isActive={isCurrent}
|
isActive={isCurrent}
|
||||||
model={family.id}
|
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)}
|
onSelectModel={nextModel => controller.select(nextModel, group.provider.slug)}
|
||||||
onSetOptions={patch =>
|
onSetOptions={patch =>
|
||||||
controller.setOptions(patch, {
|
controller.setOptions(patch, {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ function renderSubmenu(opts: {
|
|||||||
fastControl: FastControl
|
fastControl: FastControl
|
||||||
isActive?: boolean
|
isActive?: boolean
|
||||||
onSelectModel?: (model: string) => void
|
onSelectModel?: (model: string) => void
|
||||||
|
onOpenDetails?: () => void
|
||||||
onSetOptions: (patch: { effort?: string; fast?: boolean }) => void
|
onSetOptions: (patch: { effort?: string; fast?: boolean }) => void
|
||||||
reasoning: boolean
|
reasoning: boolean
|
||||||
}) {
|
}) {
|
||||||
@@ -43,6 +44,7 @@ function renderSubmenu(opts: {
|
|||||||
fastControl={opts.fastControl}
|
fastControl={opts.fastControl}
|
||||||
isActive={opts.isActive ?? true}
|
isActive={opts.isActive ?? true}
|
||||||
model="m1"
|
model="m1"
|
||||||
|
onOpenDetails={opts.onOpenDetails}
|
||||||
onSelectModel={opts.onSelectModel ?? vi.fn()}
|
onSelectModel={opts.onSelectModel ?? vi.fn()}
|
||||||
onSetOptions={opts.onSetOptions}
|
onSetOptions={opts.onSetOptions}
|
||||||
provider="p1"
|
provider="p1"
|
||||||
@@ -60,6 +62,19 @@ function renderSubmenu(opts: {
|
|||||||
// ever writes directly again, picking an effort for a kanban card would reach
|
// ever writes directly again, picking an effort for a kanban card would reach
|
||||||
// over and change the user's live chat.
|
// over and change the user's live chat.
|
||||||
describe('ModelEditSubmenu reports edits without performing them', () => {
|
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', () => {
|
it('param fast: reports the toggle', () => {
|
||||||
const onSetOptions = vi.fn()
|
const onSetOptions = vi.fn()
|
||||||
renderSubmenu({ fastControl: { kind: 'param', on: true }, onSetOptions, reasoning: false })
|
renderSubmenu({ fastControl: { kind: 'param', on: true }, onSetOptions, reasoning: false })
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ interface ModelEditSubmenuProps {
|
|||||||
isActive: boolean
|
isActive: boolean
|
||||||
/** This row's model id. */
|
/** This row's model id. */
|
||||||
model: string
|
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). */
|
/** Switch to a specific model id (used to swap base ⇄ -fast variant). */
|
||||||
onSelectModel: (model: string) => Promise<boolean | void> | void
|
onSelectModel: (model: string) => Promise<boolean | void> | void
|
||||||
/** Report an option change. This submenu is PURE: it never writes to a
|
/** Report an option change. This submenu is PURE: it never writes to a
|
||||||
@@ -107,6 +109,7 @@ function ModelEditSubmenuBody({
|
|||||||
effort,
|
effort,
|
||||||
fastControl,
|
fastControl,
|
||||||
isActive,
|
isActive,
|
||||||
|
onOpenDetails,
|
||||||
onSelectModel,
|
onSelectModel,
|
||||||
onSetOptions,
|
onSetOptions,
|
||||||
reasoning
|
reasoning
|
||||||
@@ -140,11 +143,19 @@ function ModelEditSubmenuBody({
|
|||||||
const hasFast = fastControl.kind !== 'none'
|
const hasFast = fastControl.kind !== 'none'
|
||||||
const fastOn = fastControl.kind === 'none' ? false : fastControl.on
|
const fastOn = fastControl.kind === 'none' ? false : fastControl.on
|
||||||
|
|
||||||
return !hasFast && !reasoning ? (
|
return !hasFast && !reasoning && !onOpenDetails ? (
|
||||||
<div className="px-2.5 py-3 text-xs text-(--ui-text-tertiary)">{copy.noOptions}</div>
|
<div className="px-2.5 py-3 text-xs text-(--ui-text-tertiary)">{copy.noOptions}</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<DropdownMenuLabel className={dropdownMenuSectionLabel}>{copy.options}</DropdownMenuLabel>
|
{onOpenDetails ? (
|
||||||
|
<>
|
||||||
|
<DropdownMenuItem className={dropdownMenuRow} onSelect={onOpenDetails}>
|
||||||
|
{copy.comboDetails}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{hasFast || reasoning ? <DropdownMenuSeparator className="mx-0" /> : null}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{hasFast || reasoning ? <DropdownMenuLabel className={dropdownMenuSectionLabel}>{copy.options}</DropdownMenuLabel> : null}
|
||||||
{showThinkingToggle ? (
|
{showThinkingToggle ? (
|
||||||
<DropdownMenuItem className={dropdownMenuRow} onSelect={event => event.preventDefault()}>
|
<DropdownMenuItem className={dropdownMenuRow} onSelect={event => event.preventDefault()}>
|
||||||
{copy.thinking}
|
{copy.thinking}
|
||||||
|
|||||||
@@ -2235,6 +2235,7 @@ export const ar = defineLocale({
|
|||||||
fast: 'سريع'
|
fast: 'سريع'
|
||||||
},
|
},
|
||||||
modelOptions: {
|
modelOptions: {
|
||||||
|
comboDetails: 'تفاصيل النموذج / المجموعة',
|
||||||
noOptions: 'لا توجد خيارات لهذا النموذج',
|
noOptions: 'لا توجد خيارات لهذا النموذج',
|
||||||
options: 'الخيارات',
|
options: 'الخيارات',
|
||||||
thinking: 'التفكير',
|
thinking: 'التفكير',
|
||||||
|
|||||||
@@ -2999,6 +2999,7 @@ export const en: Translations = {
|
|||||||
fast: 'Fast'
|
fast: 'Fast'
|
||||||
},
|
},
|
||||||
modelOptions: {
|
modelOptions: {
|
||||||
|
comboDetails: 'Model / combo details',
|
||||||
noOptions: 'No options for this model',
|
noOptions: 'No options for this model',
|
||||||
options: 'Options',
|
options: 'Options',
|
||||||
thinking: 'Thinking',
|
thinking: 'Thinking',
|
||||||
|
|||||||
@@ -2624,6 +2624,7 @@ export const ja = defineLocale({
|
|||||||
fast: '高速'
|
fast: '高速'
|
||||||
},
|
},
|
||||||
modelOptions: {
|
modelOptions: {
|
||||||
|
comboDetails: 'モデル / コンボの詳細',
|
||||||
noOptions: 'このモデルにはオプションがありません',
|
noOptions: 'このモデルにはオプションがありません',
|
||||||
options: 'オプション',
|
options: 'オプション',
|
||||||
thinking: '思考',
|
thinking: '思考',
|
||||||
|
|||||||
@@ -3022,6 +3022,7 @@ export const ru = defineLocale({
|
|||||||
fast: 'Быстрая'
|
fast: 'Быстрая'
|
||||||
},
|
},
|
||||||
modelOptions: {
|
modelOptions: {
|
||||||
|
comboDetails: 'Сведения о модели / комбинации',
|
||||||
noOptions: 'Для этой модели нет опций',
|
noOptions: 'Для этой модели нет опций',
|
||||||
options: 'Опции',
|
options: 'Опции',
|
||||||
thinking: 'Размышление',
|
thinking: 'Размышление',
|
||||||
|
|||||||
@@ -1281,6 +1281,7 @@ export const trOverrides: TranslationOverrides = {
|
|||||||
fast: 'Hızlı'
|
fast: 'Hızlı'
|
||||||
},
|
},
|
||||||
modelOptions: {
|
modelOptions: {
|
||||||
|
comboDetails: 'Model / combo ayrıntıları',
|
||||||
noOptions: 'Bu model için seçenek yok',
|
noOptions: 'Bu model için seçenek yok',
|
||||||
options: 'Seçenekler',
|
options: 'Seçenekler',
|
||||||
thinking: 'Düşünme',
|
thinking: 'Düşünme',
|
||||||
|
|||||||
@@ -2546,6 +2546,7 @@ export interface Translations {
|
|||||||
fast: string
|
fast: string
|
||||||
}
|
}
|
||||||
modelOptions: {
|
modelOptions: {
|
||||||
|
comboDetails: string
|
||||||
noOptions: string
|
noOptions: string
|
||||||
options: string
|
options: string
|
||||||
thinking: string
|
thinking: string
|
||||||
|
|||||||
@@ -2523,6 +2523,7 @@ export const zhHant = defineLocale({
|
|||||||
fast: '快速'
|
fast: '快速'
|
||||||
},
|
},
|
||||||
modelOptions: {
|
modelOptions: {
|
||||||
|
comboDetails: '模型 / 組合詳細資料',
|
||||||
noOptions: '此模型沒有可用選項',
|
noOptions: '此模型沒有可用選項',
|
||||||
options: '選項',
|
options: '選項',
|
||||||
thinking: '思考',
|
thinking: '思考',
|
||||||
|
|||||||
@@ -3147,6 +3147,7 @@ export const zh: Translations = {
|
|||||||
fast: '快速'
|
fast: '快速'
|
||||||
},
|
},
|
||||||
modelOptions: {
|
modelOptions: {
|
||||||
|
comboDetails: '模型 / 组合详情',
|
||||||
noOptions: '此模型没有可用选项',
|
noOptions: '此模型没有可用选项',
|
||||||
options: '选项',
|
options: '选项',
|
||||||
thinking: '思考',
|
thinking: '思考',
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+1
-1
@@ -65,7 +65,7 @@
|
|||||||
},
|
},
|
||||||
"apps/desktop": {
|
"apps/desktop": {
|
||||||
"name": "aiturk-ide",
|
"name": "aiturk-ide",
|
||||||
"version": "1.0.0-beta.9",
|
"version": "1.0.0-beta.10",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@assistant-ui/core": "0.2.23",
|
"@assistant-ui/core": "0.2.23",
|
||||||
|
|||||||
Reference in New Issue
Block a user