feat(settings): add Codex link controls and authenticated phone bridge
This commit is contained in:
1 parent
1acb5dd90f
commit
55a1beaf9d
22 files changed
+2857
-3
No files matched your search
@@ -0,0 +1,196 @@
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { I18nProvider } from '@/i18n/context'
|
||||
|
||||
import type {
|
||||
CodexLinkContextView,
|
||||
CodexLinkControlRequest,
|
||||
CodexLinkControlResponse,
|
||||
CodexLinkSettings as SavedSettings
|
||||
} from '../../../codex-link-control.types'
|
||||
|
||||
import { CodexLinkSettings } from './codex-link-settings'
|
||||
|
||||
let settings: SavedSettings
|
||||
let views: CodexLinkContextView[]
|
||||
let writes: CodexLinkControlRequest[]
|
||||
let failWrite: boolean
|
||||
|
||||
const control = vi.fn(async (request: CodexLinkControlRequest): Promise<CodexLinkControlResponse> => {
|
||||
const base = {
|
||||
contract_version: 'codex-link/1' as const,
|
||||
request_id: request.request_id,
|
||||
operation_id: request.operation_id
|
||||
}
|
||||
|
||||
if (request.kind === 'status_get') {
|
||||
return {
|
||||
...base,
|
||||
kind: 'status_result',
|
||||
status: {
|
||||
settings_revision: settings.settings_revision,
|
||||
runtime_mode: 'managed',
|
||||
native_available: true,
|
||||
external_admission: 'open',
|
||||
needs_restart: false,
|
||||
catalog_state: 'fresh',
|
||||
active_turns: 0,
|
||||
uncertain_operations: 0,
|
||||
last_operation_id: null,
|
||||
error: null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (request.kind === 'context_views_get') {
|
||||
return { ...base, kind: 'context_views_result', settings_revision: settings.settings_revision, views }
|
||||
}
|
||||
|
||||
if (request.kind === 'settings_update') {
|
||||
writes.push(request)
|
||||
|
||||
if (failWrite) {
|
||||
throw new Error('outcome_unknown')
|
||||
}
|
||||
|
||||
settings = { ...settings, ...request.patch, settings_revision: settings.settings_revision + 1 }
|
||||
} else if (request.kind !== 'settings_get') {
|
||||
writes.push(request)
|
||||
|
||||
return {
|
||||
...base,
|
||||
kind: 'control_accepted',
|
||||
settings_revision: settings.settings_revision,
|
||||
operation_state: 'accepted',
|
||||
error: null
|
||||
}
|
||||
}
|
||||
|
||||
return { ...base, kind: 'settings_result', settings, available_sources: [] }
|
||||
})
|
||||
|
||||
const mount = () =>
|
||||
render(
|
||||
<I18nProvider configClient={null} initialLocale="tr">
|
||||
<CodexLinkSettings />
|
||||
</I18nProvider>
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
control.mockClear()
|
||||
writes = []
|
||||
views = []
|
||||
failWrite = false
|
||||
settings = {
|
||||
settings_revision: 7,
|
||||
enabled: false,
|
||||
compression_enabled: true,
|
||||
threshold_tokens: 250000,
|
||||
source_binding_handle: null,
|
||||
compact_route_id: 'Compact',
|
||||
legacy_intent_imported: true,
|
||||
ownership_manifest_revision: 2,
|
||||
activation_state: 'disabled'
|
||||
}
|
||||
vi.stubGlobal('hermesDesktop', { codexLink: { control } } satisfies Pick<NonNullable<typeof window.hermesDesktop>, 'codexLink'>)
|
||||
})
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('Codex connection settings', () => {
|
||||
it('shows the persisted disabled preference, without turning it on during mount', async () => {
|
||||
mount()
|
||||
const toggle = await screen.findByRole('switch', { name: 'Modelleri Codex’e bağla' })
|
||||
expect(toggle.getAttribute('aria-checked')).toBe('false')
|
||||
expect(writes).toEqual([])
|
||||
expect((screen.getByLabelText('Sıkıştırma eşiği (token)') as HTMLInputElement).value).toBe('250000')
|
||||
})
|
||||
it('uses a checkpoint-safe settings patch and rejects decimal/unsafe thresholds', async () => {
|
||||
mount()
|
||||
const input = await screen.findByLabelText('Sıkıştırma eşiği (token)')
|
||||
fireEvent.change(input, { target: { value: '1.5' } })
|
||||
expect((screen.getByRole('button', { name: 'Kaydet' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
fireEvent.change(input, { target: { value: '9007199254740992' } })
|
||||
expect((screen.getByRole('button', { name: 'Kaydet' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
fireEvent.change(input, { target: { value: '125000' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Kaydet' }))
|
||||
await waitFor(() => expect(writes).toHaveLength(1))
|
||||
expect(writes[0]).toMatchObject({
|
||||
kind: 'settings_update',
|
||||
expected_settings_revision: 7,
|
||||
patch: { threshold_tokens: 125000 }
|
||||
})
|
||||
})
|
||||
it('keeps an uncertain operation across remount and reconciles the exact ID/body', async () => {
|
||||
failWrite = true
|
||||
const first = mount()
|
||||
fireEvent.click(await screen.findByRole('switch', { name: 'Modelleri Codex’e bağla' }))
|
||||
await screen.findByRole('button', { name: 'İşlemi kontrol et' })
|
||||
expect(writes).toHaveLength(1)
|
||||
const original = writes[0]
|
||||
first.unmount()
|
||||
mount()
|
||||
failWrite = false
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'İşlemi kontrol et' }))
|
||||
await waitFor(() => expect(writes).toHaveLength(2))
|
||||
expect(writes[1]).toEqual(original)
|
||||
await waitFor(() => expect(sessionStorage.getItem('aiturk.codex-link.pending-control.v1')).toBeNull())
|
||||
})
|
||||
it('does not present unknown token measurement as zero or enable unsafe compaction', async () => {
|
||||
views = [
|
||||
{
|
||||
view_handle: 'view-a',
|
||||
can_compact: false,
|
||||
label: 'Ayrı sohbet',
|
||||
checkpoint_revision: 3,
|
||||
current_model_label: 'Model A',
|
||||
limiting_target_id: null,
|
||||
target_budgets: null,
|
||||
active_summary_count: 2,
|
||||
summary_tokens: null,
|
||||
summary_token_measurement: null,
|
||||
last_operation_id: null,
|
||||
last_operation_state: null,
|
||||
last_operation_error: null,
|
||||
compact_attempts: []
|
||||
}
|
||||
]
|
||||
mount()
|
||||
await screen.findByText('Ayrı sohbet')
|
||||
expect(screen.getAllByText(/Henüz ölçülmedi/).length).toBeGreaterThanOrEqual(3)
|
||||
expect((screen.getByRole('button', { name: 'Şimdi sıkıştır' }) as HTMLButtonElement).disabled).toBe(true)
|
||||
})
|
||||
it('binds manual compaction to a manager view and its exact checkpoint revision', async () => {
|
||||
views = [
|
||||
{
|
||||
view_handle: 'opaque-view-a',
|
||||
can_compact: true,
|
||||
label: 'Sohbet A',
|
||||
checkpoint_revision: 9,
|
||||
current_model_label: 'Model A',
|
||||
limiting_target_id: null,
|
||||
target_budgets: null,
|
||||
active_summary_count: 0,
|
||||
summary_tokens: null,
|
||||
summary_token_measurement: null,
|
||||
last_operation_id: null,
|
||||
last_operation_state: null,
|
||||
last_operation_error: null,
|
||||
compact_attempts: []
|
||||
}
|
||||
]
|
||||
mount()
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Şimdi sıkıştır' }))
|
||||
await waitFor(() => expect(writes).toHaveLength(1))
|
||||
expect(writes[0]).toMatchObject({
|
||||
kind: 'context_compact_view',
|
||||
view_handle: 'opaque-view-a',
|
||||
expected_checkpoint_revision: 9
|
||||
})
|
||||
expect(writes[0]).not.toHaveProperty('thread_id')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,465 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useCodexLinkCopy } from '@/i18n/codex-link'
|
||||
import { Cpu } from '@/lib/icons'
|
||||
|
||||
import { validateControlRequest } from '../../../codex-link-control'
|
||||
import type {
|
||||
CodexLinkContextView,
|
||||
CodexLinkControlRequest,
|
||||
CodexLinkControlResponse,
|
||||
CodexLinkSettingsResult,
|
||||
CodexLinkSettingsUpdate,
|
||||
CodexLinkStatusSnapshot
|
||||
} from '../../../codex-link-control.types'
|
||||
|
||||
import { SettingsContent, SettingsSection } from './primitives'
|
||||
|
||||
const PENDING_KEY = 'aiturk.codex-link.pending-control.v1'
|
||||
|
||||
function envelope() {
|
||||
return {
|
||||
contract_version: 'codex-link/1' as const,
|
||||
request_id: crypto.randomUUID(),
|
||||
operation_id: crypto.randomUUID()
|
||||
}
|
||||
}
|
||||
|
||||
function readPending(): CodexLinkControlRequest | null {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(PENDING_KEY)
|
||||
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
|
||||
const value: unknown = JSON.parse(raw)
|
||||
validateControlRequest(value)
|
||||
|
||||
return ['settings_update', 'catalog_sync', 'context_compact_view'].includes(value.kind) ? value : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function savedPending(value: CodexLinkControlRequest | null) {
|
||||
if (value) {
|
||||
sessionStorage.setItem(PENDING_KEY, JSON.stringify(value))
|
||||
} else {
|
||||
sessionStorage.removeItem(PENDING_KEY)
|
||||
}
|
||||
}
|
||||
|
||||
function Preference({
|
||||
id,
|
||||
label,
|
||||
description,
|
||||
checked,
|
||||
disabled,
|
||||
onChange
|
||||
}: {
|
||||
id: string
|
||||
label: string
|
||||
description: string
|
||||
checked: boolean
|
||||
disabled: boolean
|
||||
onChange: (value: boolean) => void
|
||||
}) {
|
||||
return (
|
||||
<label className="flex min-h-11 cursor-pointer items-center gap-4 py-3" htmlFor={id}>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium">{label}</span>
|
||||
<span className="mt-1 block text-sm text-muted-foreground">{description}</span>
|
||||
</span>
|
||||
<Switch aria-label={label} checked={checked} disabled={disabled} id={id} onCheckedChange={onChange} />
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export function CodexLinkSettings() {
|
||||
const copy = useCodexLinkCopy()
|
||||
const [settings, setSettings] = useState<CodexLinkSettingsResult | null>(null)
|
||||
const [status, setStatus] = useState<CodexLinkStatusSnapshot | null>(null)
|
||||
const [views, setViews] = useState<CodexLinkContextView[]>([])
|
||||
const [threshold, setThreshold] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [pending, setPending] = useState(readPending)
|
||||
const [error, setError] = useState('')
|
||||
const [notice, setNotice] = useState('')
|
||||
const mounted = useRef(true)
|
||||
const writeLock = useRef(false)
|
||||
const readLock = useRef(false)
|
||||
const generation = useRef(0)
|
||||
const savedRevision = useRef(-1)
|
||||
const bridge = window.hermesDesktop?.codexLink
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!bridge || readLock.current || writeLock.current) {
|
||||
return
|
||||
}
|
||||
|
||||
readLock.current = true
|
||||
const current = generation.current
|
||||
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
['settings_get', 'status_get', 'context_views_get'].map(kind =>
|
||||
bridge.control({ ...envelope(), kind } as CodexLinkControlRequest)
|
||||
)
|
||||
)
|
||||
|
||||
if (!mounted.current || current !== generation.current) {
|
||||
return
|
||||
}
|
||||
|
||||
const [saved, live, chats] = results
|
||||
|
||||
if (saved.kind !== 'settings_result' || live.kind !== 'status_result' || chats.kind !== 'context_views_result') {
|
||||
throw new Error('read_failed')
|
||||
}
|
||||
|
||||
// A write in another window may fall between these three reads. Never paint a mixed revision.
|
||||
if (
|
||||
saved.settings.settings_revision !== live.status.settings_revision ||
|
||||
saved.settings.settings_revision !== chats.settings_revision
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
if (saved.settings.settings_revision < savedRevision.current) {
|
||||
return
|
||||
}
|
||||
|
||||
if (savedRevision.current !== saved.settings.settings_revision) {
|
||||
setThreshold(String(saved.settings.threshold_tokens))
|
||||
}
|
||||
|
||||
savedRevision.current = saved.settings.settings_revision
|
||||
setSettings(saved)
|
||||
setStatus(live.status)
|
||||
setViews(chats.views)
|
||||
setError('')
|
||||
} catch {
|
||||
if (mounted.current && current === generation.current) {
|
||||
setError(copy.failed)
|
||||
}
|
||||
} finally {
|
||||
readLock.current = false
|
||||
}
|
||||
}, [bridge, copy.failed])
|
||||
|
||||
// This is a component lifetime flag, not a mirror of a reactive atom/value.
|
||||
// eslint-disable-next-line no-restricted-syntax
|
||||
useEffect(() => {
|
||||
mounted.current = true
|
||||
void refresh()
|
||||
|
||||
const timer = setInterval(() => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void refresh()
|
||||
}
|
||||
}, 5000)
|
||||
|
||||
return () => {
|
||||
mounted.current = false
|
||||
// The current numeric generation is invalidated; it is not a captured DOM ref.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
generation.current++
|
||||
clearInterval(timer)
|
||||
}
|
||||
}, [refresh])
|
||||
|
||||
const send = async (request: CodexLinkControlRequest) => {
|
||||
if (!bridge || writeLock.current) {
|
||||
return
|
||||
}
|
||||
|
||||
writeLock.current = true
|
||||
generation.current++
|
||||
setBusy(true)
|
||||
setError('')
|
||||
setNotice('')
|
||||
|
||||
// Persist the exact operation before dispatch: an uncertain reply must reuse this ID and body.
|
||||
try {
|
||||
savedPending(request)
|
||||
setPending(request)
|
||||
} catch {
|
||||
writeLock.current = false
|
||||
setBusy(false)
|
||||
setError(copy.failed)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result: CodexLinkControlResponse = await bridge.control(request)
|
||||
|
||||
if (!mounted.current) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
(result.kind === 'control_error' && result.error.recovery_action === 'reconcile_only') ||
|
||||
(result.kind === 'control_accepted' && result.operation_state === 'indeterminate')
|
||||
) {
|
||||
setError(copy.unknown)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
savedPending(null)
|
||||
setPending(null)
|
||||
|
||||
if (result.kind === 'control_error') {
|
||||
setNotice(
|
||||
result.error.code.includes('conflict') || result.error.code.includes('cas')
|
||||
? copy.conflict
|
||||
: result.error.message
|
||||
)
|
||||
} else if (result.kind === 'settings_result') {
|
||||
savedRevision.current = result.settings.settings_revision
|
||||
setSettings(result)
|
||||
setThreshold(String(result.settings.threshold_tokens))
|
||||
setNotice(copy.saved)
|
||||
} else if (result.kind === 'control_accepted') {
|
||||
setNotice(result.error?.message ?? copy.accepted)
|
||||
}
|
||||
} catch {
|
||||
if (mounted.current) {
|
||||
setError(copy.unknown)
|
||||
}
|
||||
} finally {
|
||||
writeLock.current = false
|
||||
|
||||
if (mounted.current) {
|
||||
setBusy(false)
|
||||
void refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const update = (patch: CodexLinkSettingsUpdate['patch']) => {
|
||||
if (settings && !pending) {
|
||||
void send({
|
||||
...envelope(),
|
||||
kind: 'settings_update',
|
||||
expected_settings_revision: settings.settings.settings_revision,
|
||||
patch
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const thresholdValue = /^[0-9]+$/.test(threshold) ? Number(threshold) : NaN
|
||||
const validThreshold = Number.isSafeInteger(thresholdValue) && thresholdValue > 0
|
||||
const locked = busy || pending !== null
|
||||
const number = (n: number | null) => (n === null ? copy.unmeasured : n.toLocaleString())
|
||||
|
||||
return (
|
||||
<SettingsContent>
|
||||
<div className="min-w-0 py-4" data-testid="codex-link-settings">
|
||||
<SettingsSection icon={Cpu} title={copy.title}>
|
||||
<p className="mb-4 text-sm text-muted-foreground">{copy.intro}</p>
|
||||
{!bridge ? (
|
||||
<p role="alert">{copy.unavailable}</p>
|
||||
) : !settings ? (
|
||||
<p role="status">{error || copy.loading}</p>
|
||||
) : (
|
||||
<>
|
||||
<Preference
|
||||
checked={settings.settings.enabled}
|
||||
description={copy.enabledHelp}
|
||||
disabled={locked}
|
||||
id="codex-link-enabled"
|
||||
label={copy.enabled}
|
||||
onChange={enabled => update({ enabled })}
|
||||
/>
|
||||
<Preference
|
||||
checked={settings.settings.compression_enabled}
|
||||
description={copy.compressionHelp}
|
||||
disabled={locked}
|
||||
id="codex-link-compression"
|
||||
label={copy.compression}
|
||||
onChange={compression_enabled => update({ compression_enabled })}
|
||||
/>
|
||||
<div className="my-4 space-y-2">
|
||||
<label className="block text-sm font-medium" htmlFor="codex-link-threshold">
|
||||
{copy.threshold}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Input
|
||||
aria-describedby="codex-link-threshold-help"
|
||||
aria-invalid={!validThreshold}
|
||||
className="min-h-11 w-full min-w-0 sm:w-48"
|
||||
disabled={locked}
|
||||
id="codex-link-threshold"
|
||||
inputMode="numeric"
|
||||
onChange={event => setThreshold(event.target.value)}
|
||||
value={threshold}
|
||||
/>
|
||||
<Button
|
||||
className="min-h-11"
|
||||
disabled={locked || !validThreshold || thresholdValue === settings.settings.threshold_tokens}
|
||||
onClick={() => update({ threshold_tokens: thresholdValue })}
|
||||
>
|
||||
{copy.save}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground" id="codex-link-threshold-help">
|
||||
{validThreshold ? copy.thresholdHelp : copy.invalidThreshold}
|
||||
</p>
|
||||
</div>
|
||||
<div className="my-4 space-y-2">
|
||||
<label className="block text-sm font-medium" htmlFor="codex-link-source">
|
||||
{copy.source}
|
||||
</label>
|
||||
{settings.available_sources.length ? (
|
||||
<select
|
||||
className="min-h-11 w-full min-w-0 rounded-md border bg-background px-3 text-sm"
|
||||
disabled={locked}
|
||||
id="codex-link-source"
|
||||
onChange={event => update({ source_binding_handle: event.target.value })}
|
||||
value={settings.settings.source_binding_handle ?? ''}
|
||||
>
|
||||
{!settings.settings.source_binding_handle && (
|
||||
<option disabled value="">
|
||||
{copy.noSource}
|
||||
</option>
|
||||
)}
|
||||
{settings.available_sources.map(source => (
|
||||
<option
|
||||
disabled={!source.authorized}
|
||||
key={source.source_binding_handle}
|
||||
value={source.source_binding_handle}
|
||||
>
|
||||
{source.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">{copy.noSource}</p>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm" role="status">
|
||||
{copy.activation[settings.settings.activation_state]}
|
||||
{status ? ` · ${copy.admission[status.external_admission]}` : ''}
|
||||
</p>
|
||||
{status?.needs_restart && <p className="mt-2 text-sm text-muted-foreground">{copy.restart}</p>}
|
||||
{status?.error && (
|
||||
<p className="mt-2 text-sm" role="alert">
|
||||
{status.error.message}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
<Button
|
||||
className="min-h-11"
|
||||
disabled={locked}
|
||||
onClick={() => void send({ ...envelope(), kind: 'catalog_sync' })}
|
||||
variant="outline"
|
||||
>
|
||||
{copy.sync}
|
||||
</Button>
|
||||
<Button className="min-h-11" disabled={busy} onClick={() => void refresh()} variant="outline">
|
||||
{copy.refresh}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{pending && (
|
||||
<div className="mt-4 space-y-2">
|
||||
<p className="text-sm" role="alert">
|
||||
{copy.unknown}
|
||||
</p>
|
||||
<Button className="min-h-11" disabled={busy} onClick={() => void send(pending)}>
|
||||
{copy.reconcile}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{error && settings && (
|
||||
<p className="mt-3 text-sm" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{notice && (
|
||||
<p className="mt-3 text-sm" role="status">
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
</SettingsSection>
|
||||
{settings && (
|
||||
<SettingsSection icon={Cpu} title={copy.chats}>
|
||||
{!views.length && <p className="text-sm text-muted-foreground">{copy.empty}</p>}
|
||||
{views.map(view => {
|
||||
const target = view.target_budgets?.find(item => item.target_id === view.limiting_target_id)
|
||||
|
||||
const hardLimit = target
|
||||
? Math.min(
|
||||
target.max_input_tokens ?? Number.MAX_SAFE_INTEGER,
|
||||
target.context_window_tokens - target.output_cap_tokens - target.safety_margin_tokens
|
||||
)
|
||||
: null
|
||||
|
||||
const limit =
|
||||
hardLimit === null
|
||||
? null
|
||||
: settings.settings.compression_enabled
|
||||
? Math.min(settings.settings.threshold_tokens, hardLimit)
|
||||
: hardLimit
|
||||
|
||||
return (
|
||||
<article className="min-w-0 border-b py-4 last:border-b-0" key={view.view_handle}>
|
||||
<h3 className="break-words text-sm font-medium">{view.label}</h3>
|
||||
<p className="break-words text-sm text-muted-foreground">{view.current_model_label}</p>
|
||||
<dl className="my-3 grid grid-cols-[minmax(0,1fr)_minmax(0,1fr)] gap-2 text-sm">
|
||||
<dt>{copy.current}</dt>
|
||||
<dd className="break-words">{number(target?.input_tokens ?? null)}</dd>
|
||||
<dt>{copy.limit}</dt>
|
||||
<dd className="break-words">{number(limit)}</dd>
|
||||
<dt>{copy.summaries}</dt>
|
||||
<dd>
|
||||
{view.active_summary_count} · {number(view.summary_tokens)}{' '}
|
||||
{view.summary_tokens !== null ? copy.tokens : ''}
|
||||
</dd>
|
||||
</dl>
|
||||
{view.last_operation_state && (
|
||||
<p className="my-2 text-sm" role="status">
|
||||
{copy.operation[view.last_operation_state]}
|
||||
</p>
|
||||
)}
|
||||
{view.last_operation_error && (
|
||||
<p className="my-2 text-sm" role="alert">
|
||||
{view.last_operation_error.message}
|
||||
</p>
|
||||
)}
|
||||
{view.compact_attempts.map(attempt => (
|
||||
<p className="break-words text-sm text-muted-foreground" key={attempt.attempt_id}>
|
||||
{attempt.member_label} · {copy.operation[attempt.state]}
|
||||
</p>
|
||||
))}
|
||||
<Button
|
||||
className="mt-2 min-h-11"
|
||||
disabled={locked || !view.can_compact}
|
||||
onClick={() =>
|
||||
void send({
|
||||
...envelope(),
|
||||
kind: 'context_compact_view',
|
||||
view_handle: view.view_handle,
|
||||
expected_checkpoint_revision: view.checkpoint_revision
|
||||
})
|
||||
}
|
||||
variant="outline"
|
||||
>
|
||||
{copy.compact}
|
||||
</Button>
|
||||
</article>
|
||||
)
|
||||
})}
|
||||
</SettingsSection>
|
||||
)}
|
||||
</div>
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { Tip } from '@/components/ui/tooltip'
|
||||
import { getHermesConfigDefaults, getHermesConfigRecord, saveHermesConfig } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { useAiturkCopy } from '@/i18n/aiturk'
|
||||
import { useCodexLinkCopy } from '@/i18n/codex-link'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import {
|
||||
Archive,
|
||||
@@ -47,6 +48,7 @@ import { AboutSettings } from './about-settings'
|
||||
import { AppearanceSettings } from './appearance-settings'
|
||||
import { BillingSettings } from './billing'
|
||||
import { ConfigSettings } from './config-settings'
|
||||
import { CodexLinkSettings } from './codex-link-settings'
|
||||
import { SECTIONS } from './constants'
|
||||
import { GatewaySettings } from './gateway-settings'
|
||||
import { KeybindSettings } from './keybind-settings'
|
||||
@@ -61,6 +63,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
||||
...SECTIONS.map(s => `config:${s.id}` as SettingsViewId),
|
||||
'providers',
|
||||
'gateway',
|
||||
'codex-link',
|
||||
// Legacy alias: the Connections page merged into Gateways. Kept in the enum
|
||||
// so saved `?tab=connections` deep links still resolve (redirected below).
|
||||
'connections',
|
||||
@@ -76,6 +79,7 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [
|
||||
export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: SettingsPageProps) {
|
||||
const { t } = useI18n()
|
||||
const aiturkCopy = useAiturkCopy()
|
||||
const codexLinkCopy = useCodexLinkCopy()
|
||||
const navigate = useNavigate()
|
||||
const { hash, pathname, search } = useLocation()
|
||||
|
||||
@@ -252,6 +256,13 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
||||
label: t.settings.nav.gateway,
|
||||
onSelect: () => setActiveView('gateway')
|
||||
},
|
||||
{
|
||||
active: activeView === 'codex-link',
|
||||
icon: Cpu,
|
||||
id: 'codex-link',
|
||||
label: codexLinkCopy.title,
|
||||
onSelect: () => setActiveView('codex-link')
|
||||
},
|
||||
// "Telefon bağlantısı" lives in its own small window (QR + code); this
|
||||
// entry only opens it. Present only where the desktop bridge offers it.
|
||||
...(window.hermesDesktop?.aiturkMobile
|
||||
@@ -320,7 +331,7 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
||||
onSelect: () => setActiveView('about')
|
||||
}
|
||||
],
|
||||
[activeView, keysView, providerView, t, aiturkCopy, setActiveView, openProviderView, openKeysView]
|
||||
[activeView, keysView, providerView, t, aiturkCopy, codexLinkCopy, setActiveView, openProviderView, openKeysView]
|
||||
)
|
||||
|
||||
// Type-to-search: printable keystrokes on the Settings surface (outside any
|
||||
@@ -414,6 +425,8 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set
|
||||
// 'connections' renders the unified page too so the frame before
|
||||
// the alias redirect lands doesn't flash the fallback view.
|
||||
<GatewaySettings />
|
||||
) : activeView === 'codex-link' ? (
|
||||
<CodexLinkSettings />
|
||||
) : activeView === 'keybinds' ? (
|
||||
<KeybindSettings />
|
||||
) : activeView.startsWith('config:') ? (
|
||||
|
||||
@@ -8,6 +8,7 @@ export type SettingsView =
|
||||
| 'about'
|
||||
| 'billing'
|
||||
| 'connections'
|
||||
| 'codex-link'
|
||||
| 'gateway'
|
||||
| 'keybinds'
|
||||
| 'keys'
|
||||
|
||||
@@ -39,7 +39,7 @@ describe('ShareControls', () => {
|
||||
const dialog = await screen.findByRole('dialog')
|
||||
const input = within(dialog).getByRole('textbox')
|
||||
fireEvent.change(input, { target: { value: 'not a valid code !!!' } })
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Yükle', exact: true }))
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Yükle' }))
|
||||
expect(within(dialog).getByText('Bu metin bir harita koduna benzemiyor.')).toBeTruthy()
|
||||
expect((input as HTMLTextAreaElement).value).toBe('not a valid code !!!')
|
||||
})
|
||||
|
||||
Vendored
+2
@@ -2,6 +2,7 @@ import type { GatewayWsUrlResult } from '@hermes/shared'
|
||||
import type { TranslucencyState } from '@hermes/shared/translucency'
|
||||
|
||||
import type { PoolLimits } from '../electron/pool-limits'
|
||||
import type { CodexLinkControlRequest, CodexLinkControlResponse } from '../codex-link-control.types'
|
||||
|
||||
import type { WakeIndicatorState } from './lib/wake-indicator'
|
||||
import type {
|
||||
@@ -17,6 +18,7 @@ export {}
|
||||
declare global {
|
||||
interface Window {
|
||||
hermesDesktop: {
|
||||
codexLink?: { control: (request: CodexLinkControlRequest) => Promise<CodexLinkControlResponse> }
|
||||
setUiLanguage?: (locale: string) => void
|
||||
// Resolve a backend connection. Omit `profile` (or pass the primary) for
|
||||
// the window's backend; pass a named profile to lazily spawn/reuse that
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useI18n } from './context'
|
||||
|
||||
const en = {
|
||||
title: 'Connect Codex',
|
||||
intro: 'Use this IDE’s models and combos in Codex. Each chat keeps its own memory.',
|
||||
enabled: 'Connect models to Codex',
|
||||
enabledHelp: 'Enabled on first installation. Your choice is saved.',
|
||||
compression: 'Automatic compaction',
|
||||
compressionHelp:
|
||||
'Checks the complete request before sending. Earlier summaries are retained as the available context allows.',
|
||||
threshold: 'Compaction threshold (tokens)',
|
||||
thresholdHelp:
|
||||
'The effective threshold is lower when a model has less available input space. Output space is reserved too.',
|
||||
invalidThreshold: 'Enter a positive whole number.',
|
||||
save: 'Save',
|
||||
source: 'Connected account',
|
||||
noSource: 'No authorized model source is available.',
|
||||
sync: 'Refresh models',
|
||||
refresh: 'Refresh status',
|
||||
loading: 'Reading saved settings…',
|
||||
unavailable: 'The local Codex connection service is unavailable. Check the AITURK installation.',
|
||||
failed: 'Could not read the latest state.',
|
||||
unknown: 'The result is not confirmed. Check this same operation before making another change.',
|
||||
reconcile: 'Check operation',
|
||||
conflict: 'Settings changed elsewhere. The latest saved state has been loaded.',
|
||||
saved: 'Settings saved.',
|
||||
accepted: 'Operation accepted. Its progress appears below.',
|
||||
chats: 'Chat memory',
|
||||
empty: 'No managed Codex chat is currently visible.',
|
||||
compact: 'Compact now',
|
||||
summaries: 'Saved summaries',
|
||||
tokens: 'tokens',
|
||||
unmeasured: 'Not measured yet',
|
||||
current: 'Current input',
|
||||
limit: 'Effective threshold',
|
||||
restart: 'The next Codex launch will use the new runtime. Existing chats keep their current process.',
|
||||
activation: {
|
||||
disabled: 'Disabled',
|
||||
pending_runtime_acceptance: 'Runtime verification pending',
|
||||
ready: 'Ready',
|
||||
restart_required: 'Restart required',
|
||||
conflict: 'Configuration conflict',
|
||||
recovery_pending: 'Recovering the saved state'
|
||||
},
|
||||
admission: {
|
||||
open: 'External models available',
|
||||
closed: 'External models unavailable',
|
||||
draining: 'Finishing active work',
|
||||
recovery_required: 'Operation recovery required'
|
||||
},
|
||||
operation: {
|
||||
accepted: 'Accepted',
|
||||
prepared: 'Prepared',
|
||||
running: 'Running',
|
||||
completed: 'Completed',
|
||||
failed: 'Failed',
|
||||
indeterminate: 'Result unknown'
|
||||
}
|
||||
}
|
||||
|
||||
const tr: typeof en = {
|
||||
title: 'Codex Bağlama',
|
||||
intro: 'Bu IDE’deki modelleri ve comboları Codex’te kullanın. Her sohbet kendi hafızasını tutar.',
|
||||
enabled: 'Modelleri Codex’e bağla',
|
||||
enabledHelp: 'İlk kurulumda açıktır. Değiştirdiğiniz tercih kaydedilir.',
|
||||
compression: 'Otomatik sıkıştırma',
|
||||
compressionHelp:
|
||||
'Gönderilmeden önce isteğin tamamı kontrol edilir. Önceki özetler kullanılabilir bağlamın izin verdiği ölçüde korunur.',
|
||||
threshold: 'Sıkıştırma eşiği (token)',
|
||||
thresholdHelp:
|
||||
'Modelin kullanılabilir giriş alanı daha küçükse etkin eşik düşer. Yanıt için gereken alan da ayrılır.',
|
||||
invalidThreshold: 'Pozitif bir tam sayı girin.',
|
||||
save: 'Kaydet',
|
||||
source: 'Bağlı hesap',
|
||||
noSource: 'Yetkili model kaynağı bulunamadı.',
|
||||
sync: 'Modelleri yenile',
|
||||
refresh: 'Durumu yenile',
|
||||
loading: 'Kayıtlı ayarlar okunuyor…',
|
||||
unavailable: 'Yerel Codex bağlantı hizmetine ulaşılamıyor. AITURK kurulumunu kontrol edin.',
|
||||
failed: 'Güncel durum okunamadı.',
|
||||
unknown: 'İşlemin sonucu henüz doğrulanmadı. Yeni değişiklikten önce aynı işlemin durumunu kontrol edin.',
|
||||
reconcile: 'İşlemi kontrol et',
|
||||
conflict: 'Ayarlar başka bir yerde değişti. Güncel kayıtlı ayarlar yüklendi.',
|
||||
saved: 'Ayarlar kaydedildi.',
|
||||
accepted: 'İşlem kabul edildi. İlerlemesi aşağıda gösterilir.',
|
||||
chats: 'Sohbet hafızası',
|
||||
empty: 'Şu anda görünen yönetilen Codex sohbeti yok.',
|
||||
compact: 'Şimdi sıkıştır',
|
||||
summaries: 'Kayıtlı özetler',
|
||||
tokens: 'token',
|
||||
unmeasured: 'Henüz ölçülmedi',
|
||||
current: 'Mevcut giriş',
|
||||
limit: 'Etkin eşik',
|
||||
restart: 'Yeni runtime sonraki Codex açılışında kullanılacak. Açık sohbetler mevcut süreçleriyle devam eder.',
|
||||
activation: {
|
||||
disabled: 'Kapalı',
|
||||
pending_runtime_acceptance: 'Runtime doğrulaması bekleniyor',
|
||||
ready: 'Hazır',
|
||||
restart_required: 'Yeniden açılış gerekli',
|
||||
conflict: 'Ayar çakışması',
|
||||
recovery_pending: 'Kayıtlı durum kurtarılıyor'
|
||||
},
|
||||
admission: {
|
||||
open: 'Harici modeller kullanılabilir',
|
||||
closed: 'Harici modeller kullanılamıyor',
|
||||
draining: 'Etkin işler bitiriliyor',
|
||||
recovery_required: 'İşlem durumu uzlaştırılmalı'
|
||||
},
|
||||
operation: {
|
||||
accepted: 'Kabul edildi',
|
||||
prepared: 'Hazırlandı',
|
||||
running: 'Çalışıyor',
|
||||
completed: 'Tamamlandı',
|
||||
failed: 'Başarısız',
|
||||
indeterminate: 'Sonuç belirsiz'
|
||||
}
|
||||
}
|
||||
|
||||
export function useCodexLinkCopy() {
|
||||
return useI18n().locale === 'tr' ? tr : en
|
||||
}
|
||||
Reference in new issue
Block a user