From 8f53b090f79f762328b3d192f9a57348d0cb0425 Mon Sep 17 00:00:00 2001 From: yilsem Date: Tue, 13 Oct 2026 19:56:40 +0300 Subject: [PATCH] feat(codex-link): persist direct subscription and AITURK connection modes --- .../electron/codex-link-config.test.ts | 21 ++++++- apps/desktop/electron/codex-link-config.ts | 21 +++++-- apps/desktop/electron/codex-link-types.ts | 4 ++ apps/desktop/electron/codex-link.test.ts | 55 ++++++++++++++++++- apps/desktop/electron/codex-link.ts | 15 ++++- apps/desktop/electron/main.ts | 1 + apps/desktop/package.json | 2 +- .../app/settings/codex-link-settings.test.tsx | 16 ++++++ .../src/app/settings/codex-link-settings.tsx | 23 +++++++- apps/desktop/src/i18n/codex-link.ts | 45 ++++++++++++++- package-lock.json | 2 +- 11 files changed, 188 insertions(+), 17 deletions(-) diff --git a/apps/desktop/electron/codex-link-config.test.ts b/apps/desktop/electron/codex-link-config.test.ts index c0f09b9..d0bdabf 100644 --- a/apps/desktop/electron/codex-link-config.test.ts +++ b/apps/desktop/electron/codex-link-config.test.ts @@ -225,12 +225,17 @@ describe('preference file', () => { }) it('writes the contract shape and keeps unknown fields', () => { - expect(JSON.parse(serializeCodexLinkSettings({}, false))).toEqual({ enabled: false, kapsam: 'ide' }) + expect(JSON.parse(serializeCodexLinkSettings({}, false))).toEqual({ + enabled: false, + mod: 'dogrudan', + kapsam: 'ide' + }) const { stored } = parseCodexLinkSettings('{"enabled": false, "kapsam": "hepsi", "gelecek": {"a": 1}}') expect(JSON.parse(serializeCodexLinkSettings(stored, true))).toEqual({ enabled: true, + mod: 'dogrudan', gelecek: { a: 1 }, kapsam: 'hepsi' }) @@ -308,3 +313,17 @@ describe('bridge configuration and command', () => { expect(codexAutostartPlan(`${deep}\\pythonw.exe`, launch, `${deep}\\baslat.pyw`)).toBeNull() }) }) + +describe('mode preference', () => { + it('uses direct for missing, invalid and malformed modes', () => { + for (const raw of [null, '{', '{}', '{"mod":"bad"}', '{"mod":false}']) { + expect(parseCodexLinkSettings(raw).mod).toBe('dogrudan') + } + expect(parseCodexLinkSettings('{"mod":"aiturk"}').mod).toBe('aiturk') + }) + it('preserves other fields when changing mode', () => { + const stored = { enabled: false, mod: 'aiturk', kapsam: 'hepsi', future: { a: 1 } } + expect(JSON.parse(serializeCodexLinkSettings(stored, false, 'dogrudan'))).toEqual({ ...stored, mod: 'dogrudan' }) + expect(JSON.parse(serializeCodexLinkSettings(stored, true)).mod).toBe('aiturk') + }) +}) diff --git a/apps/desktop/electron/codex-link-config.ts b/apps/desktop/electron/codex-link-config.ts index 9571270..dd05e7d 100644 --- a/apps/desktop/electron/codex-link-config.ts +++ b/apps/desktop/electron/codex-link-config.ts @@ -300,6 +300,7 @@ export function removeCodexLinkLine(source: string): CodexConfigEdit { export interface CodexLinkSettings { enabled: boolean + mod: 'dogrudan' | 'aiturk' /** Every field of the stored object, so a rewrite keeps what it did not set. */ stored: Record } @@ -307,26 +308,34 @@ export interface CodexLinkSettings { /** Missing, unreadable or malformed means ON: the feature is on by default. */ export function parseCodexLinkSettings(raw: null | string | undefined): CodexLinkSettings { if (typeof raw !== 'string') { - return { enabled: true, stored: {} } + return { enabled: true, mod: 'dogrudan', stored: {} } } try { const parsed = JSON.parse(raw) if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - return { enabled: true, stored: {} } + return { enabled: true, mod: 'dogrudan', stored: {} } } - return { enabled: parsed.enabled !== false, stored: parsed as Record } + return { + enabled: parsed.enabled !== false, + mod: parsed.mod === 'aiturk' ? 'aiturk' : 'dogrudan', + stored: parsed as Record + } } catch { - return { enabled: true, stored: {} } + return { enabled: true, mod: 'dogrudan', stored: {} } } } -export function serializeCodexLinkSettings(stored: Record, enabled: boolean): string { +export function serializeCodexLinkSettings( + stored: Record, + enabled: boolean, + mod: 'dogrudan' | 'aiturk' = stored.mod === 'aiturk' ? 'aiturk' : 'dogrudan' +): string { const kapsam = typeof stored.kapsam === 'string' && stored.kapsam ? stored.kapsam : 'ide' - return `${JSON.stringify({ ...stored, enabled, kapsam }, null, 2)}\n` + return `${JSON.stringify({ ...stored, enabled, mod, kapsam }, null, 2)}\n` } // ── Bridge configuration ──────────────────────────────────────────────────── diff --git a/apps/desktop/electron/codex-link-types.ts b/apps/desktop/electron/codex-link-types.ts index d925eeb..11078df 100644 --- a/apps/desktop/electron/codex-link-types.ts +++ b/apps/desktop/electron/codex-link-types.ts @@ -5,6 +5,8 @@ * code into its program. See codex-link.ts for what the fields mean in action. */ +export type CodexLinkMode = 'dogrudan' | 'aiturk' + export type CodexLinkIdeState = 'anahtar-yok' | 'kapali' | 'tamam' /** Stable codes; the renderer owns the wording. Detail goes to the desktop log. */ @@ -18,6 +20,7 @@ export type CodexLinkError = export interface CodexLinkStatus { enabled: boolean + mod: CodexLinkMode /** Codex's `config.toml` exists on this machine. */ codexFound: boolean /** The IDE's own Python environment exists (first-run setup finished). */ @@ -34,6 +37,7 @@ export interface CodexLinkStatus { export interface CodexLinkPatch { enabled?: boolean + mod?: CodexLinkMode /** "Sync now": re-run the apply without changing the preference. */ sync?: boolean } diff --git a/apps/desktop/electron/codex-link.test.ts b/apps/desktop/electron/codex-link.test.ts index 9301c45..e9e2e18 100644 --- a/apps/desktop/electron/codex-link.test.ts +++ b/apps/desktop/electron/codex-link.test.ts @@ -52,6 +52,7 @@ function createWorld(patch: Partial & { files?: Map } = { events: [], files: new Map([ [PATHS.codexConfig, USER_CONFIG], + [PATHS.settingsFile, JSON.stringify({ enabled: true, mod: 'aiturk' })], [PATHS.bridgeScript, '# bridge'], [PATHS.pythonw, ''], [PATHS.python, ''] @@ -192,6 +193,7 @@ describe('applyCodexLink', () => { expect(status).toEqual({ enabled: true, + mod: 'aiturk', codexFound: true, venvReady: true, foreign: false, @@ -328,7 +330,11 @@ describe('applyCodexLink', () => { 'autostart clear', 'stop' ]) - expect(JSON.parse(world.files.get(PATHS.settingsFile) ?? '')).toEqual({ enabled: false, kapsam: 'ide' }) + expect(JSON.parse(world.files.get(PATHS.settingsFile) ?? '')).toEqual({ + enabled: false, + mod: 'aiturk', + kapsam: 'ide' + }) // Byte for byte what the user had before the feature touched it. expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG) expect(world.autostart).toBeNull() @@ -344,7 +350,8 @@ describe('applyCodexLink', () => { world.configWriteFails = true expect(await link.apply({ enabled: false })).toMatchObject({ - bridgeRunning: true, lastError: 'config-write-failed' + bridgeRunning: true, + lastError: 'config-write-failed' }) expect(world.files.get(PATHS.codexConfig)).toBe(linkedConfig) expect(world.autostart).toBe(autostart) @@ -358,7 +365,7 @@ describe('applyCodexLink', () => { it('stays off across applies and comes back when turned on', async () => { const { link, world } = createWorld() - world.files.set(PATHS.settingsFile, '{"enabled": false, "kapsam": "ide", "not": "ours"}') + world.files.set(PATHS.settingsFile, '{"enabled": false, "mod": "aiturk", "kapsam": "ide", "not": "ours"}') expect(await link.apply()).toMatchObject({ enabled: false, bridgeRunning: false }) expect(spawns(world)).toEqual([]) @@ -367,6 +374,7 @@ describe('applyCodexLink', () => { expect(await link.apply({ enabled: true })).toMatchObject({ enabled: true, bridgeRunning: true }) expect(JSON.parse(world.files.get(PATHS.settingsFile) ?? '')).toEqual({ enabled: true, + mod: 'aiturk', kapsam: 'ide', not: 'ours' }) @@ -424,3 +432,44 @@ describe('getStatus', () => { expect(effects(world)).toEqual([]) }) }) + +describe('connection mode', () => { + it('defaults to direct, starts bridge and autostart without writing a line', async () => { + const { link, world } = createWorld() + world.files.delete(PATHS.settingsFile) + expect(await link.apply()).toMatchObject({ mod: 'dogrudan', bridgeRunning: true }) + expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG) + expect(spawns(world)).toHaveLength(1) + expect(world.autostart).not.toBeNull() + }) + it('removes the old line and preserves the current bridge in direct mode', async () => { + const { link, world } = createWorld({ listening: true }) + world.files.set(PATHS.settingsFile, '{"mod":"dogrudan"}') + world.files.set(PATHS.codexConfig, `${CODEX_LINK_LINE}\r\n${USER_CONFIG}`) + await link.apply() + expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG) + expect(spawns(world)).toHaveLength(0) + expect(world.events).not.toContain('stop') + expect(world.autostart).not.toBeNull() + }) + it('switches both ways, persists mode and preserves unrelated preferences', async () => { + const { link, world } = createWorld({ listening: true }) + world.files.set(PATHS.settingsFile, '{"mod":"dogrudan","custom":{"keep":1}}') + await link.apply({ mod: 'aiturk' }) + expect(world.files.get(PATHS.codexConfig)).toContain(CODEX_LINK_LINE) + await link.apply({ mod: 'dogrudan' }) + expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG) + expect(JSON.parse(world.files.get(PATHS.settingsFile)!)).toMatchObject({ mod: 'dogrudan', custom: { keep: 1 } }) + await link.apply() + expect(world.files.get(PATHS.codexConfig)).toBe(USER_CONFIG) + expect(world.events).not.toContain('stop') + }) + it.each(['dogrudan', 'aiturk'] as const)('preserves foreign URL in %s', async mod => { + const { link, world } = createWorld() + const foreign = 'openai_base_url = "https://other.example"\n' + world.files.set(PATHS.codexConfig, foreign) + await link.apply({ mod }) + expect(world.files.get(PATHS.codexConfig)).toBe(foreign) + expect(spawns(world)).toHaveLength(0) + }) +}) diff --git a/apps/desktop/electron/codex-link.ts b/apps/desktop/electron/codex-link.ts index b0bfa28..a8c3b14 100644 --- a/apps/desktop/electron/codex-link.ts +++ b/apps/desktop/electron/codex-link.ts @@ -186,6 +186,7 @@ export function createCodexLink(deps: CodexLinkDeps): CodexLink { bridge: BridgeProbe ): CodexLinkStatus => ({ enabled, + mod: readSettings().mod, codexFound: config.found, venvReady: interpreter() !== null, foreign: config.owner === 'foreign', @@ -329,7 +330,8 @@ export function createCodexLink(deps: CodexLinkDeps): CodexLink { return bridge } - const edit = addCodexLinkLine(config.text ?? '') + const edit = + readSettings().mod === 'aiturk' ? addCodexLinkLine(config.text ?? '') : removeCodexLinkLine(config.text ?? '') if (edit.changed) { try { @@ -364,9 +366,16 @@ export function createCodexLink(deps: CodexLinkDeps): CodexLink { const run = async (patch?: CodexLinkPatch): Promise => { lastError = null - if (typeof patch?.enabled === 'boolean') { + if (typeof patch?.enabled === 'boolean' || patch?.mod === 'aiturk' || patch?.mod === 'dogrudan') { try { - deps.writeTextAtomic(paths.settingsFile, serializeCodexLinkSettings(readSettings().stored, patch.enabled)) + deps.writeTextAtomic( + paths.settingsFile, + serializeCodexLinkSettings( + readSettings().stored, + patch?.enabled ?? readSettings().enabled, + patch?.mod ?? readSettings().mod + ) + ) } catch (error) { // The preference did not stick, so do not act on it either: what the // machine does must match what the file says after a restart. diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index dd763b0..2f671c3 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -17318,6 +17318,7 @@ ipcMain.handle('aiturk:codex-link:set', async (event, patch) => { return codexLink.apply({ enabled: typeof patch?.enabled === 'boolean' ? patch.enabled : undefined, + mod: patch?.mod === 'aiturk' || patch?.mod === 'dogrudan' ? patch.mod : undefined, sync: patch?.sync === true }) }) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 8dbc6ed..f4f34e2 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.37", + "version": "1.0.0-beta.38", "description": "TurkServis resmi yapay zekâ geliştirme ortamı. Hermes Agent tabanlıdır.", "author": "AITURK / TurkServis", "repository": { diff --git a/apps/desktop/src/app/settings/codex-link-settings.test.tsx b/apps/desktop/src/app/settings/codex-link-settings.test.tsx index 024095b..ae1cfeb 100644 --- a/apps/desktop/src/app/settings/codex-link-settings.test.tsx +++ b/apps/desktop/src/app/settings/codex-link-settings.test.tsx @@ -12,6 +12,7 @@ vi.mock('@/store/notifications', () => ({ notifyError })) const RUNNING: CodexLinkStatus = { enabled: true, + mod: 'aiturk', codexFound: true, venvReady: true, foreign: false, @@ -198,3 +199,18 @@ describe('Codex Link settings', () => { expect(get).not.toHaveBeenCalled() }) }) + + +describe('connection mode controls', () => { + it('sends the mode and renders the saved direct preference', async () => { + get.mockResolvedValue({ ...RUNNING, mod: 'dogrudan' }) + set.mockResolvedValue({ ...RUNNING, mod: 'aiturk' }) + renderPage() + const direct = await screen.findByRole('radio', { name: 'Doğrudan (önerilen): abonelik modelleri köprüden bağımsız' }) + await waitFor(() => expect((direct as HTMLInputElement).checked).toBe(true)) + fireEvent.click(screen.getByRole('radio', { name: 'AITURK: combolar Codex listesinde, tüm modeller köprüden' })) + await waitFor(() => expect(set).toHaveBeenCalledWith({ mod: 'aiturk' })) + await waitFor(() => expect((direct as HTMLInputElement).checked).toBe(false)) + expect(screen.getByText("Codex'i yeniden başlatın")).toBeTruthy() + }) +}) diff --git a/apps/desktop/src/app/settings/codex-link-settings.tsx b/apps/desktop/src/app/settings/codex-link-settings.tsx index 2899519..cd7c374 100644 --- a/apps/desktop/src/app/settings/codex-link-settings.tsx +++ b/apps/desktop/src/app/settings/codex-link-settings.tsx @@ -57,7 +57,7 @@ export function codexLinkStatusLines(status: CodexLinkStatus, copy: CodexLinkCop if (status.ideDurum === 'anahtar-yok') { lines.push({ id: 'ide', text: copy.signIn, tone: 'warn' }) - } else if (status.ideDurum === 'tamam' && status.ideModel !== null) { + } else if (status.mod === 'aiturk' && status.ideDurum === 'tamam' && status.ideModel !== null) { lines.push({ id: 'ide', text: copy.models(status.ideModel), tone: 'ok' }) } @@ -170,6 +170,27 @@ export function CodexLinkSettings() { title={copy.title} /> + + {(['dogrudan', 'aiturk'] as const).map(mod => ( + + ))} +

{copy.modeRestartNote}

+ + } + /> + {!status && !loadFailed && } {loadFailed && !status && ( diff --git a/apps/desktop/src/i18n/codex-link.ts b/apps/desktop/src/i18n/codex-link.ts index 5345958..8e6001e 100644 --- a/apps/desktop/src/i18n/codex-link.ts +++ b/apps/desktop/src/i18n/codex-link.ts @@ -6,6 +6,10 @@ import { useI18n } from './context' // AITURK-distribution strings (see ./aiturk): Turkish for `tr`, English for // every other locale. const en = { + modeTitle: 'Connection mode', + directMode: 'Direct (recommended): subscription models independent of the bridge', + aiturkMode: 'AITURK: combos in the Codex list, all models through the bridge', + modeRestartNote: 'Restart Codex.', nav: 'Codex Link', title: 'Codex Link', description: @@ -39,6 +43,10 @@ const en = { } const tr: typeof en = { + modeTitle: 'Bağlantı modu', + directMode: 'Doğrudan (önerilen): abonelik modelleri köprüden bağımsız', + aiturkMode: 'AITURK: combolar Codex listesinde, tüm modeller köprüden', + modeRestartNote: "Codex'i yeniden başlatın", nav: 'Codex Bağlama', title: 'Codex Bağlama', description: @@ -71,10 +79,45 @@ const tr: typeof en = { restartNote: "Codex'te yeni modelleri görmek için Codex'i yeniden açın." } +const modeTranslations = { + zh: { + modeTitle: '连接模式', + directMode: '直接(推荐):订阅模型独立于桥接', + aiturkMode: 'AITURK:组合显示在 Codex 列表中,所有模型通过桥接', + modeRestartNote: '请重启 Codex。' + }, + 'zh-hant': { + modeTitle: '連線模式', + directMode: '直接(建議):訂閱模型獨立於橋接', + aiturkMode: 'AITURK:組合顯示在 Codex 清單中,所有模型透過橋接', + modeRestartNote: '請重新啟動 Codex。' + }, + ja: { + modeTitle: '接続モード', + directMode: '直接(推奨):サブスクリプションモデルはブリッジから独立', + aiturkMode: 'AITURK:Codex の一覧にコンボを表示、すべてのモデルはブリッジ経由', + modeRestartNote: 'Codex を再起動してください。' + }, + ar: { + modeTitle: 'وضع الاتصال', + directMode: 'مباشر (موصى به): نماذج الاشتراك مستقلة عن الجسر', + aiturkMode: 'AITURK: مجموعات النماذج في قائمة Codex، وجميع النماذج عبر الجسر', + modeRestartNote: 'أعد تشغيل Codex.' + }, + ru: { + modeTitle: 'Режим подключения', + directMode: 'Напрямую (рекомендуется): модели подписки независимы от моста', + aiturkMode: 'AITURK: комбинации в списке Codex, все модели через мост', + modeRestartNote: 'Перезапустите Codex.' + } +} + export type CodexLinkCopy = typeof en export function useCodexLinkCopy(): CodexLinkCopy { const { locale } = useI18n() - return locale === 'tr' ? tr : en + if (locale === 'tr') return tr + if (locale === 'en') return en + return { ...en, ...modeTranslations[locale] } } diff --git a/package-lock.json b/package-lock.json index 237c50e..cd8232e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,7 +65,7 @@ }, "apps/desktop": { "name": "aiturk-ide", - "version": "1.0.0-beta.37", + "version": "1.0.0-beta.38", "license": "MIT", "dependencies": { "@assistant-ui/core": "0.2.23",