SPB Git

Repository Public

page.tsx 7.7 KB 127 lines tsx

1'use client';2import { useEffect, useState } from 'react';3import type { AosSettings, Effort, Provider } from '@aos/shared';4import { DEFAULT_SETTINGS, useModels, useSaveSettings, useSettingsQuery } from '@/lib/queries';5import { errorMessage } from '@/lib/api';6import { toast } from '@/stores/toasts';7import { EFFORT_LABEL, PROVIDER_LABEL, TIER_LABEL } from '@/lib/status';8import { Input } from '@/components/ui/input';9import { Select } from '@/components/ui/select';10import { Switch } from '@/components/ui/switch';11import { ErrorState, Section, Skeleton } from '@/components/ui/misc';12import { SecuritySettings } from '@/components/settings/security';13import { ThemeSegmented } from '@/components/shell/theme-toggle';1415function Row({ label, hint, children }: { label: string; hint?: React.ReactNode; children: React.ReactNode }) {16  return (17    <div className="flex flex-wrap items-center gap-x-6 gap-y-2 px-4 py-3">18      <div className="min-w-0 flex-1 basis-56">19        <p className="text-[13px] font-medium text-text">{label}</p>20        {hint && <p className="mt-0.5 text-xs leading-relaxed text-muted">{hint}</p>}21      </div>22      <div className="w-full shrink-0 sm:w-64">{children}</div>23    </div>24  );25}26/** Number input committed on blur/Enter, clamped to the schema bounds. */27function NumberSetting({ value, min, max, onCommit, label, unit, integer, testId }: { value: number; min: number; max: number; onCommit: (n: number) => void; label: string; unit: string; integer?: boolean; testId?: string }) {28  const [text, setText] = useState(String(value));29  useEffect(() => setText(String(value)), [value]);30  const commit = () => {31    const raw = Number(text);32    if (!Number.isFinite(raw)) return setText(String(value));33    const c = Math.max(min, Math.min(max, integer ? Math.round(raw) : Math.round(raw * 100) / 100));34    setText(String(c));35    if (c !== value) onCommit(c);36  };37  return (38    <div className="flex items-center gap-2">39      <Input aria-label={label} inputMode={integer ? 'numeric' : 'decimal'} value={text} onChange={(e) => setText(e.target.value)} onBlur={commit} onKeyDown={(e) => e.key === 'Enter' && commit()} className="tnum" data-testid={testId} />40      <span className="w-24 shrink-0 text-[11px] text-muted">{unit} ({min}–{max})</span>41    </div>42  );43}4445export default function SettingsPage() {46  const q = useSettingsQuery();47  const save = useSaveSettings();48  const models = useModels().data ?? [];49  const s = q.data ?? DEFAULT_SETTINGS;50  const set = (patch: Partial<AosSettings>) => save(patch).catch((e) => toast.error('Could not save settings', errorMessage(e)));51  const ofProvider = models.filter((m) => m.provider === s.defaultProvider);52  const model = models.find((m) => m.id === s.defaultModel);53  const efforts = model?.efforts ?? (['low', 'medium', 'high', 'xhigh', 'max'] as Effort[]);54  return (55    <div className="h-full overflow-y-auto">56      {q.isPending ? (57        <div className="mx-auto flex max-w-3xl flex-col gap-4 p-4 md:p-8">{[0, 1, 2].map((i) => <Skeleton key={i} className="h-32" />)}</div>58      ) : q.isError ? (59        <ErrorState error={q.error} onRetry={() => q.refetch()} />60      ) : (61        <div className="mx-auto flex max-w-3xl flex-col gap-5 px-4 pb-32 pt-1 md:px-8 md:pb-12 md:pt-6" data-testid="settings">62          <div className="hidden md:block">63            <h1 className="text-xl font-bold tracking-tight">Settings</h1>64            <p className="text-[13px] text-text-2">Saved on the server for this administrator. Changes apply to missions started afterwards.</p>65          </div>66          <Section title="Mission defaults">67            <div className="divide-y divide-line">68              <Row label="Provider">69                <Select70                  label="Default provider"71                  value={s.defaultProvider}72                  onValueChange={(v) => {73                    const first = models.find((m) => m.provider === v && m.available && m.tier === 'main') ?? models.find((m) => m.provider === v && m.available);74                    void set({ defaultProvider: v as Provider, ...(first ? { defaultModel: first.id } : {}) });75                  }}76                  options={(['anthropic', 'openai'] as Provider[]).map((p) => ({ value: p, label: PROVIDER_LABEL[p] }))}77                />78              </Row>79              <Row label="Model" hint="Used when a mission does not pick one. Unavailable models are not listed.">80                <Select81                  label="Default model"82                  value={s.defaultModel}83                  onValueChange={(v) => set({ defaultModel: v })}84                  options={[...ofProvider.filter((m) => m.available || m.id === s.defaultModel).map((m) => ({ value: m.id, label: m.label, hint: TIER_LABEL[m.tier] })), ...(model || !s.defaultModel ? [] : [{ value: s.defaultModel, label: s.defaultModel }])]}85                />86              </Row>87              <Row label="Reasoning effort">88                <Select label="Default effort" value={s.defaultEffort} onValueChange={(v) => set({ defaultEffort: v as Effort })} options={efforts.map((e) => ({ value: e, label: EFFORT_LABEL[e] }))} />89              </Row>90            </div>91          </Section>92          <Section title="Budgets and limits">93            <div className="divide-y divide-line">94              <Row label="Mission budget" hint="Default hard stop of a new mission. Can be changed per mission.">95                <NumberSetting label="Mission budget" value={s.missionBudgetUsd} min={0.01} max={1000} unit="USD" onCommit={(n) => set({ missionBudgetUsd: n })} testId="set-mission-budget" />96              </Row>97              <Row label="Daily cap" hint="Across all missions. Once today's spend reaches it, model calls stop and new missions are refused.">98                <NumberSetting label="Daily cap" value={s.dailyBudgetUsd} min={0.01} max={100000} unit="USD" onCommit={(n) => set({ dailyBudgetUsd: n })} testId="set-daily-budget" />99              </Row>100              <Row label="Concurrent runs" hint="How many runs the worker executes at the same time.">101                <NumberSetting label="Max concurrent runs" value={s.maxConcurrentRuns} min={1} max={32} unit="runs" integer onCommit={(n) => set({ maxConcurrentRuns: n })} testId="set-concurrency" />102              </Row>103              <Row label="Approval timeout" hint="A request you have not answered after this long expires, which counts as a denial.">104                <NumberSetting label="Approval timeout" value={s.approvalTimeoutHours} min={1} max={336} unit="hours" integer onCommit={(n) => set({ approvalTimeoutHours: n })} testId="set-approval-timeout" />105              </Row>106            </div>107          </Section>108          <Section title="Appearance">109            <Row label="Theme" hint="Light by default. “System” follows this device."><ThemeSegmented /></Row>110          </Section>111          <Section title="Notifications">112            <div className="divide-y divide-line">113              <Row label="In-app notifications" hint="A toast when an agent needs an approval or an answer while the console is open.">114                <Switch label="In-app notifications" checked={s.notifications.inApp} onCheckedChange={(v) => set({ notifications: { ...s.notifications, inApp: v } })} />115              </Row>116              <Row label="Detail level" hint="Generic text never shows what the agent wants to do; “titles” includes the request title.">117                <Select label="Notification detail" value={s.notifications.detail} onValueChange={(v) => set({ notifications: { ...s.notifications, detail: v as 'generic' | 'titles' } })} options={[{ value: 'generic', label: 'Generic' }, { value: 'titles', label: 'Include titles' }]} />118              </Row>119            </div>120          </Section>121          <SecuritySettings />122        </div>123      )}124    </div>125  );126}127
Files main
Menu

Theme