feat(codex-link): choose which models fill the Codex model menu and beta.41
The Codex desktop app's model picker asks the app server for one page of
at most 100 models and never asks for the next page (a fixed limit in the
app). AITURK sent 262, so everything after the first 100 in alphabetical
provider order was out of reach from the menu (Gemini, most NanoGPT
uncensored models, Grok, ModelArk...).
- Bridge: bridge.json codex_liste {medya_gizle, efor_gizle, one_al, gizli},
re-read when the file changes. Image/audio/video models and "-low/-high"
copies whose base model is listed are left out by default; pinned models
follow Codex's own models in the given order; hidden ones are removed.
The candidate list with each model's place is written to
log/son-liste/codex-adaylar.json for the settings screen.
- Settings > Codex Bağlama: "Models shown in Codex" with a live count of the
100 slots, search, filter, pin/move up/hide per model and the two switches.
- The IDE keeps codex_liste when it rewrites bridge.json.
- Bridge version 21, IDE 1.0.0-beta.41.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
81ee2ca646
commit
5c76c24c9c
17 files changed
+696
-8
No files matched your search
@@ -12,6 +12,7 @@ import type { CodexLinkPatch, CodexLinkStatus } from '../../../electron/codex-li
|
||||
|
||||
import { EmptyState, ListRow, ListRowSkeleton, SettingsContent, SettingsSection } from './primitives'
|
||||
import { CompressionSettingsView } from './compression-settings'
|
||||
import { CodexListeSettingsView } from './codex-liste-settings'
|
||||
import { IsHattiSettingsView } from './is-hatti-settings'
|
||||
|
||||
type Tone = 'muted' | 'ok' | 'warn'
|
||||
@@ -253,6 +254,7 @@ export function CodexLinkSettings() {
|
||||
</p>
|
||||
</SettingsSection>
|
||||
<IsHattiSettingsView />
|
||||
<CodexListeSettingsView />
|
||||
<CompressionSettingsView />
|
||||
</SettingsContent>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
|
||||
import { I18nProvider } from '@/i18n/context'
|
||||
|
||||
import { CodexListeSettingsView } from './codex-liste-settings'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
|
||||
const aday = (slug: string, extra: Record<string, unknown> = {}) => ({ slug, ad: slug.replace('ide/', ''), kind: 'model', medya: false, efor: false, neden: '', sira: null, menude: false, ...extra })
|
||||
|
||||
it('counts the menu with the subscription models, pins and hides, and saves the choice', async () => {
|
||||
const adaylar = [aday('ide/agnes'), aday('ide/agnes-high', { efor: true }), aday('ide/veo', { medya: true }), ...Array.from({ length: 99 }, (_, i) => aday(`ide/m${String(i).padStart(2, '0')}`))]
|
||||
const save = vi.fn(async (ayar: unknown) => ({ ayar, adaylar, ozgunSayisi: 2, sinir: 100, at: 't' }))
|
||||
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
|
||||
codexLink: {
|
||||
getCodexListe: async () => ({ ayar: { medya_gizle: true, efor_gizle: true, one_al: [], gizli: [] }, adaylar, ozgunSayisi: 2, sinir: 100, at: 't' }),
|
||||
setCodexListe: save
|
||||
}
|
||||
}
|
||||
render(<I18nProvider configClient={null} initialLocale="tr"><CodexListeSettingsView /></I18nProvider>)
|
||||
// 2 subscription + 98 of the 100 shown candidates fit; agnes-high and veo are hidden.
|
||||
expect(await screen.findByText('Menüde 100 / 100 · menüye sığmayan 2 · gizli 2')).toBeTruthy()
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Model ara' }), { target: { value: 'm98' } })
|
||||
const satir = screen.getByText('m98').closest('li')!
|
||||
expect(within(satir).getByText('Menüye sığmıyor')).toBeTruthy()
|
||||
fireEvent.click(within(satir).getByRole('button', { name: 'Öne al' }))
|
||||
expect(within(satir).getByText('Menüde, 3. sıra')).toBeTruthy()
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Model ara' }), { target: { value: 'agnes' } })
|
||||
fireEvent.click(within(screen.getByText('agnes').closest('li')!).getByRole('button', { name: 'Gizle' }))
|
||||
fireEvent.click(screen.getByRole('switch', { name: 'Düşük/orta/yüksek kopyalarını gizle' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Kaydet' }))
|
||||
await waitFor(() => expect(save).toHaveBeenCalledWith({ medya_gizle: true, efor_gizle: false, one_al: ['ide/m98'], gizli: ['ide/agnes'] }))
|
||||
expect(await screen.findByText(/Codex model listesini birkaç dakika içinde yeniler/)).toBeTruthy()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Varsayılana dön' }))
|
||||
await waitFor(() => expect(save).toHaveBeenLastCalledWith({ medya_gizle: true, efor_gizle: true, one_al: [], gizli: [] }))
|
||||
})
|
||||
|
||||
it('explains when the bridge has not built the list yet', async () => {
|
||||
;(window as unknown as { hermesDesktop: unknown }).hermesDesktop = {
|
||||
codexLink: { getCodexListe: async () => ({ ayar: { medya_gizle: true, efor_gizle: true, one_al: [], gizli: [] }, adaylar: [], ozgunSayisi: 0, sinir: 100, at: null }), setCodexListe: vi.fn() }
|
||||
}
|
||||
render(<I18nProvider configClient={null} initialLocale="tr"><CodexListeSettingsView /></I18nProvider>)
|
||||
expect(await screen.findByText(/Model listesi henüz oluşmadı/)).toBeTruthy()
|
||||
})
|
||||
@@ -0,0 +1,209 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useI18n } from '@/i18n/context'
|
||||
import { Link } from '@/lib/icons'
|
||||
|
||||
import {
|
||||
CODEX_LISTE_VARSAYILAN,
|
||||
type CodexAday,
|
||||
type CodexListeSettings,
|
||||
type CodexListeState,
|
||||
onizle
|
||||
} from '../../../electron/codex-liste-types'
|
||||
|
||||
import { ListRow, SettingsSection } from './primitives'
|
||||
|
||||
type Filtre = 'hepsi' | 'menude' | 'disarida' | 'gizli'
|
||||
|
||||
/** Which added models fill the Codex model picker. The Codex desktop app shows at most 100 models (its own
|
||||
* fixed limit); the user pins, hides and filters to choose which ones make it. */
|
||||
export function CodexListeSettingsView() {
|
||||
const api = window.hermesDesktop?.codexLink
|
||||
const { locale } = useI18n()
|
||||
const tr = locale === 'tr'
|
||||
const t = (a: string, b: string) => (tr ? a : b)
|
||||
const [state, setState] = useState<CodexListeState | null>(null)
|
||||
const [draft, setDraft] = useState<CodexListeSettings | null>(null)
|
||||
const [arama, setArama] = useState('')
|
||||
const [filtre, setFiltre] = useState<Filtre>('hepsi')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [message, setMessage] = useState('')
|
||||
const [failed, setFailed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
api?.getCodexListe?.().then(
|
||||
next => {
|
||||
if (active) {
|
||||
setState(next)
|
||||
setDraft(next.ayar)
|
||||
}
|
||||
},
|
||||
() => {
|
||||
if (active) {
|
||||
setFailed(true)
|
||||
setMessage(tr ? 'Codex model listesi ayarı okunamadı.' : 'Could not load the Codex model list setting.')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [api, tr])
|
||||
|
||||
const onizleme = useMemo(() => (state && draft ? onizle(state, draft) : []), [state, draft])
|
||||
|
||||
if (!api?.getCodexListe || !api.setCodexListe) return null
|
||||
|
||||
const menude = onizleme.filter(a => a.menude).length
|
||||
const disarida = onizleme.filter(a => !a.neden && !a.menude).length
|
||||
const gizlenen = onizleme.filter(a => a.neden).length
|
||||
const sinir = state?.sinir ?? 100
|
||||
const ozgun = state?.ozgunSayisi ?? 0
|
||||
|
||||
const sirali = [...onizleme].sort((a, b) => (a.sira ?? 1e9) - (b.sira ?? 1e9))
|
||||
const aranan = arama.trim().toLocaleLowerCase(locale)
|
||||
|
||||
const gorunen = sirali.filter(a => {
|
||||
if (aranan && !`${a.ad} ${a.slug}`.toLocaleLowerCase(locale).includes(aranan)) return false
|
||||
if (filtre === 'menude') return a.menude
|
||||
if (filtre === 'disarida') return !a.neden && !a.menude
|
||||
if (filtre === 'gizli') return Boolean(a.neden)
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
const degistir = (patch: Partial<CodexListeSettings>) => draft && setDraft({ ...draft, ...patch })
|
||||
|
||||
const oneAl = (slug: string) => {
|
||||
if (!draft) return
|
||||
const pinned = draft.one_al.includes(slug)
|
||||
degistir({ one_al: pinned ? draft.one_al.filter(s => s !== slug) : [...draft.one_al, slug], gizli: draft.gizli.filter(s => s !== slug) })
|
||||
}
|
||||
|
||||
const yukari = (slug: string) => {
|
||||
if (!draft) return
|
||||
const i = draft.one_al.indexOf(slug)
|
||||
if (i <= 0) return
|
||||
const next = [...draft.one_al]
|
||||
;[next[i - 1], next[i]] = [next[i], next[i - 1]]
|
||||
degistir({ one_al: next })
|
||||
}
|
||||
|
||||
const gizle = (slug: string) => {
|
||||
if (!draft) return
|
||||
const hidden = draft.gizli.includes(slug)
|
||||
degistir({ gizli: hidden ? draft.gizli.filter(s => s !== slug) : [...draft.gizli, slug], one_al: draft.one_al.filter(s => s !== slug) })
|
||||
}
|
||||
|
||||
const save = async (next: CodexListeSettings) => {
|
||||
setBusy(true)
|
||||
setMessage('')
|
||||
setFailed(false)
|
||||
|
||||
try {
|
||||
const saved = await api.setCodexListe!(next)
|
||||
setState({ ...saved, adaylar: state?.adaylar ?? saved.adaylar })
|
||||
setDraft(saved.ayar)
|
||||
setMessage(t('Kaydedildi. Codex model listesini birkaç dakika içinde yeniler (en geç 5 dakika sonra Codex penceresine dönünce).', 'Saved. Codex refreshes its model list within a few minutes (at the latest when you return to its window after 5 minutes).'))
|
||||
} catch {
|
||||
setFailed(true)
|
||||
setMessage(t('Kaydedilemedi.', 'Could not save.'))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const durum = (a: CodexAday) => {
|
||||
if (a.neden === 'gizli') return t('Gizli', 'Hidden')
|
||||
if (a.neden === 'medya') return t('Resim/ses modeli, gizli', 'Image/audio model, hidden')
|
||||
if (a.neden === 'efor') return t('Düşük/orta/yüksek kopyası, gizli', 'Effort copy, hidden')
|
||||
if (a.menude) return t(`Menüde, ${(a.sira ?? 0) + 1}. sıra`, `In the menu, position ${(a.sira ?? 0) + 1}`)
|
||||
|
||||
return t('Menüye sığmıyor', 'Does not fit in the menu')
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection icon={Link} title={t('Codex’te görünecek modeller', 'Models shown in Codex')}>
|
||||
<p className="text-sm text-(--ui-text-secondary)">
|
||||
{t(
|
||||
`Codex’in model menüsü en çok ${sinir} model gösterir; bu sınır Codex uygulamasının kendi sınırıdır. Codex’in kendi ${ozgun} modeli her zaman başta durur. Kalan yerlere hangi modellerin gireceğini burada seçin: öne aldıklarınız başa gelir, gizledikleriniz çıkar. Menüde olmayan bir modeli Codex’e “modeli … yap” diyerek yine seçebilirsiniz.`,
|
||||
`The Codex model menu shows at most ${sinir} models; this is the Codex app’s own limit. Codex’s own ${ozgun} models always come first. Choose which models fill the rest: pinned ones come first, hidden ones are removed. You can still pick a model that is not in the menu by asking Codex to switch to it.`
|
||||
)}
|
||||
</p>
|
||||
{state && draft && (
|
||||
<>
|
||||
<p className="text-sm font-medium" role="status">
|
||||
{t(`Menüde ${ozgun + menude} / ${sinir} · menüye sığmayan ${disarida} · gizli ${gizlenen}`, `In the menu ${ozgun + menude} / ${sinir} · not fitting ${disarida} · hidden ${gizlenen}`)}
|
||||
</p>
|
||||
<ListRow
|
||||
action={<Switch aria-label={t('Resim, ses ve video modellerini gizle', 'Hide image, audio and video models')} checked={draft.medya_gizle} disabled={busy} onCheckedChange={v => degistir({ medya_gizle: v })} />}
|
||||
description={t('Bunlar Codex sohbetinde çalışmaz.', 'These do not work in a Codex chat.')}
|
||||
title={t('Resim, ses ve video modellerini gizle', 'Hide image, audio and video models')}
|
||||
/>
|
||||
<ListRow
|
||||
action={<Switch aria-label={t('Düşük/orta/yüksek kopyalarını gizle', 'Hide low/medium/high copies')} checked={draft.efor_gizle} disabled={busy} onCheckedChange={v => degistir({ efor_gizle: v })} />}
|
||||
description={t('Ana modeli listede olan “-low”, “-high” gibi kopyalar gizlenir; düşünme seviyesini Codex’in kendi seçicisinden seçersiniz.', 'Copies such as “-low” or “-high” whose base model is listed are hidden; pick the reasoning level in Codex’s own selector.')}
|
||||
title={t('Düşük/orta/yüksek kopyalarını gizle', 'Hide low/medium/high copies')}
|
||||
/>
|
||||
{state.adaylar.length === 0 ? (
|
||||
<p className="text-sm text-(--ui-text-secondary)">
|
||||
{t('Model listesi henüz oluşmadı. Codex’i açıp model menüsüne bir kez bakın, sonra bu sayfayı yeniden açın.', 'The model list has not been built yet. Open the Codex model menu once, then reopen this page.')}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Input aria-label={t('Model ara', 'Search models')} className="min-h-11 min-w-0 flex-1" onChange={e => setArama(e.target.value)} placeholder={t('Model ara', 'Search models')} value={arama} />
|
||||
<select aria-label={t('Göster', 'Show')} className="min-h-11 rounded-md border bg-(--ui-bg) p-2" onChange={e => setFiltre(e.target.value as Filtre)} value={filtre}>
|
||||
<option value="hepsi">{t('Hepsi', 'All')}</option>
|
||||
<option value="menude">{t('Menüde olanlar', 'In the menu')}</option>
|
||||
<option value="disarida">{t('Menüye sığmayanlar', 'Not fitting')}</option>
|
||||
<option value="gizli">{t('Gizlenenler', 'Hidden')}</option>
|
||||
</select>
|
||||
</div>
|
||||
<ul aria-label={t('Modeller', 'Models')} className="max-h-[32rem] divide-y overflow-y-auto rounded-md border">
|
||||
{gorunen.map(a => {
|
||||
const pinned = draft.one_al.includes(a.slug)
|
||||
const hidden = draft.gizli.includes(a.slug)
|
||||
|
||||
return (
|
||||
<li className="flex flex-wrap items-center gap-2 p-2" key={a.slug}>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{a.ad}</div>
|
||||
<div className="truncate text-xs text-(--ui-text-tertiary)">{durum(a)}</div>
|
||||
</div>
|
||||
{pinned && draft.one_al.indexOf(a.slug) > 0 && (
|
||||
<Button aria-label={t(`${a.ad} bir yukarı`, `Move ${a.ad} up`)} className="min-h-11" disabled={busy} onClick={() => yukari(a.slug)} size="sm" variant="ghost">
|
||||
↑
|
||||
</Button>
|
||||
)}
|
||||
<Button aria-pressed={pinned} className="min-h-11" disabled={busy} onClick={() => oneAl(a.slug)} size="sm" variant={pinned ? 'default' : 'secondary'}>
|
||||
{pinned ? t('Öne alındı', 'Pinned') : t('Öne al', 'Pin')}
|
||||
</Button>
|
||||
<Button aria-pressed={hidden} className="min-h-11" disabled={busy} onClick={() => gizle(a.slug)} size="sm" variant="ghost">
|
||||
{hidden ? t('Göster', 'Show') : t('Gizle', 'Hide')}
|
||||
</Button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button disabled={busy} onClick={() => void save(draft)}>
|
||||
{busy ? t('Kaydediliyor…', 'Saving…') : t('Kaydet', 'Save')}
|
||||
</Button>
|
||||
<Button disabled={busy} onClick={() => void save({ ...CODEX_LISTE_VARSAYILAN, one_al: [], gizli: [] })} variant="secondary">
|
||||
{t('Varsayılana dön', 'Restore default')}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{message && <p role={failed ? 'alert' : 'status'}>{message}</p>}
|
||||
</SettingsSection>
|
||||
)
|
||||
}
|
||||
Vendored
+3
@@ -3,6 +3,7 @@ import type { TranslucencyState } from '@hermes/shared/translucency'
|
||||
|
||||
import type { CodexLinkPatch, CodexLinkStatus } from '../electron/codex-link-types'
|
||||
import type { CompressionSettings, CompressionState } from '../electron/compression-settings-types'
|
||||
import type { CodexListeSettings, CodexListeState } from '../electron/codex-liste-settings'
|
||||
import type { IsHattiSettings } from '../electron/is-hatti-settings'
|
||||
import type { PoolLimits } from '../electron/pool-limits'
|
||||
import type { StagedTransitionPatch, StagedTransitionState } from '../electron/staged-transition-types'
|
||||
@@ -347,6 +348,8 @@ declare global {
|
||||
setCompression: (settings: CompressionSettings) => Promise<CompressionState>
|
||||
getIsHatti?: () => Promise<IsHattiSettings>
|
||||
setIsHatti?: (settings: IsHattiSettings) => Promise<IsHattiSettings>
|
||||
getCodexListe?: () => Promise<CodexListeState>
|
||||
setCodexListe?: (settings: CodexListeSettings) => Promise<CodexListeState>
|
||||
getStagedTransition: () => Promise<StagedTransitionState>
|
||||
setStagedTransition: (patch: StagedTransitionPatch) => Promise<StagedTransitionState>
|
||||
get: () => Promise<CodexLinkStatus>
|
||||
|
||||
Reference in new issue
Block a user