472 lines
16 KiB
TypeScript
472 lines
16 KiB
TypeScript
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)}
|
|
{target?.count_method === 'provider_estimate' ? ` · ${copy.estimated}` : ''}
|
|
{target?.count_method === 'verified_upper_bound' ? ` · ${copy.upperBound}` : ''}
|
|
</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 : ''}
|
|
{view.summary_token_measurement?.count_method === 'provider_estimate' ? ` · ${copy.estimated}` : ''}
|
|
{view.summary_token_measurement?.count_method === 'verified_upper_bound' ? ` · ${copy.upperBound}` : ''}
|
|
</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>
|
|
)
|
|
}
|