Files
maestro/ui/src/components/settings/LlmWorkersForm.tsx
T
oss-sync ed3dc5529a
CI / build-and-test (push) Successful in 9m8s
CI / build-and-test (pull_request) Successful in 9m42s
sync: update from private repo (9a86f49b)
2026-07-12 23:51:48 +00:00

625 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { HelpText } from './HelpText';
import { EnvOverrideWarning, FieldLabel, FieldInput } from './formUtils';
import { SecretInput } from './SecretInput';
import { ModelSelect } from './ModelSelect';
import { StringArrayEditor } from './StringArrayEditor';
import { settingsFieldId, type SectionFormProps } from './types';
/**
* Worker entry shape used by the v2 `llm.workers[]` config block. The
* field names mirror what the server expects after camelCase
* conversion (see src/config.ts:transformKeys). The runtime AppConfig
* still uses `provider.workers` internally during the v1→v2 compat
* window, but the API surface and this UI are v2-only.
*/
interface LlmWorker {
id?: string;
connectionType?: 'direct' | 'aao_gateway';
endpoint?: string;
apiKey?: string;
model?: string;
roles?: string[];
maxConcurrency?: number;
enabled?: boolean;
vlm?: boolean;
/** llama.cpp の prompt 評価進捗(return_progress)を要求。llama.cpp 系専用のオプトイン。 */
returnProgress?: boolean;
healthcheckIntervalSeconds?: number;
/**
* OpenAI 互換 request body へ浅いマージする任意 JSON(例:
* `{"reasoning_effort":"max"}`)。予約キー(model/messages/stream/
* stream_options/tools/tool_choice/temperature)はクライアント側で
* 無視される。空欄はフィールド自体を省略(`{}` ではなく undefined)。
*/
extraBody?: Record<string, unknown>;
/** Phase 1 で消費される、このワーカーが対応する reasoning effort の宣言リスト。 */
reasoningEfforts?: string[];
/**
* effort をリクエストボディへ注入する形。body=トップレベル
* reasoning_effortvLLM 向け)、chat_template_kwargs=
* chat_template_kwargs.reasoning_effortllama-server 向け)。
* 未指定は body 扱い。
*/
reasoningEffortMode?: 'body' | 'chat_template_kwargs';
/**
* Phase 1 compat: older `provider.workers[].proxy: true` rows are
* mapped to `connectionType: aao_gateway` by the normalizer. We
* still surface the field name so the UI can read legacy drafts
* that haven't been migrated yet.
*/
proxy?: boolean;
}
interface LlmConfigShape {
timeoutMinutes?: number;
maxStreamMinutes?: number;
retry?: {
maxAttempts?: number;
backoffMs?: number[];
retryableStatus?: number[];
};
workers?: LlmWorker[];
}
/**
* Detect whether an `aao_gateway` worker's endpoint appears to point at
* the current AAO instance itself. Heuristic only — reverse proxies
* and deployment-specific hostnames can defeat this, so we never block
* save; the warning is purely an "are you sure?" hint.
*
* Triggers when the endpoint host is:
* - `localhost` / `127.0.0.1` / `::1`
* - the same host as `window.location.host` (excluding port mismatch
* — a separate gateway process on the same box is legitimate)
*
* Phase 2 (out of scope for this PR) will replace this with a hard
* UUID check against `/aao/instance-id`.
*/
function detectSelfLoop(endpoint: string | undefined): boolean {
if (!endpoint) return false;
let url: URL;
try {
url = new URL(endpoint);
} catch {
return false;
}
const host = url.hostname.toLowerCase();
if (host === 'localhost' || host === '127.0.0.1' || host === '::1') return true;
// Match against the browser's current hostname — same host, regardless
// of port. This catches `http://my-aao.example/v1` when the operator
// is editing settings on `my-aao.example` itself.
if (typeof window !== 'undefined' && window.location?.hostname) {
return host === window.location.hostname.toLowerCase();
}
return false;
}
/**
* JSON textarea for `worker.extraBody`. The raw text the operator is
* mid-typing lives only in this component's state (same local-draft idea as
* `SecretInput`), and `onChange` (which writes into the config draft that
* `Save & Apply` persists) is called ONLY when the text parses as a JSON
* object. Invalid JSON — including valid-but-non-object JSON like an array,
* string, or number — therefore never reaches the draft.
*
* Because the invalid draft is withheld from the config draft, a save
* triggered by ANOTHER dirty field would silently drop it — so the field
* also reports its validity upward via `onValidityChange(fieldKey, valid)`
* (SectionFormProps contract): ConfigForm disables Save & Apply while any
* key is invalid. The flag is cleared on unmount (row removed, section
* switched) so a stale key can never permanently brick Save.
*
* If the `value` prop changes to something we did not emit (Discard Changes,
* row moved/removed above this one), the local draft re-syncs from the prop
* and any error is cleared.
*
* An emptied textarea reports `undefined` (field absent) rather than `{}`.
*/
function ExtraBodyField({ value, onChange, fieldKey, onValidityChange }: {
value: Record<string, unknown> | undefined;
onChange: (v: Record<string, unknown> | undefined) => void;
/** Stable identity for the validity flag, e.g. `llm.workers.0.extraBody`. */
fieldKey: string;
onValidityChange?: (fieldKey: string, valid: boolean) => void;
}) {
const { t } = useTranslation('settings');
const [text, setText] = useState(() => (value === undefined ? '' : JSON.stringify(value, null, 2)));
const [error, setError] = useState<string | null>(null);
const inputId = settingsFieldId(fieldKey);
const errorId = `${inputId}-error`;
// Last value WE pushed via onChange. If the prop diverges from it, the
// change came from outside (discard / row shift) → re-sync the draft.
const lastEmitted = useRef(value);
useEffect(() => {
if (value !== lastEmitted.current) {
lastEmitted.current = value;
setText(value === undefined ? '' : JSON.stringify(value, null, 2));
setError(null);
}
}, [value]);
// Report validity upward; clear the flag on unmount or key change so a
// removed row / switched section never leaves Save permanently disabled.
useEffect(() => {
onValidityChange?.(fieldKey, error === null);
return () => onValidityChange?.(fieldKey, true);
}, [fieldKey, error, onValidityChange]);
const handleChange = (raw: string) => {
setText(raw);
if (raw.trim() === '') {
setError(null);
lastEmitted.current = undefined;
onChange(undefined);
return;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
setError(t('llmWorkers.extraBodyInvalid'));
return;
}
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
setError(t('llmWorkers.extraBodyInvalid'));
return;
}
setError(null);
const obj = parsed as Record<string, unknown>;
lastEmitted.current = obj;
onChange(obj);
};
return (
<div className="col-span-2">
<FieldLabel>extra_body</FieldLabel>
<textarea
id={inputId}
aria-label="extra_body"
aria-invalid={error !== null}
aria-describedby={error ? errorId : undefined}
value={text}
onChange={e => handleChange(e.target.value)}
rows={4}
placeholder={'{"reasoning_effort": "high"}'}
className={`w-full px-2.5 py-2 text-[13px] font-mono border rounded-md focus:ring-2 focus:ring-accent-ring outline-none bg-canvas ${
error ? 'border-red-400 focus:border-red-400' : 'border-hairline focus:border-accent'
}`}
/>
{error && <p id={errorId} role="alert" className="text-2xs text-red-600 mt-1">{error}</p>}
<HelpText>{t('llmWorkers.extraBodyHelp')}</HelpText>
</div>
);
}
/**
* Settings → LLM → Workers.
*
* This is the v2 replacement for the old `ProviderForm` + inline
* `WorkersBlock` pair. The big differences from the v1 forms:
*
* - reads/writes `llm.workers[]` instead of `provider.workers[]`
* (the v1 form rendered empty after the API switched to v2 shape)
* - each row carries `connectionType: direct | aao_gateway` instead
* of a `proxy: true` toggle, so the rendered help text and warnings
* can be specific to the connection style
* - api keys use the 4-state `SecretInput` editor instead of a raw
* `<input type="password">`, so masking / env-refs / clears are
* explicit and survive round-trip without a magic `'********'`
* literal sneaking back into config.yaml
* - the model field is a discovery-backed dropdown with manual
* fallback — typing a literal still works, but Ollama-style
* `/models` endpoints pre-populate the dropdown
* - roles use a chip editor instead of a comma-separated string, so
* values containing commas are no longer corrupted
* - `aao_gateway` rows show a heuristic self-loop warning when the
* endpoint host looks like the current AAO instance
*/
export function LlmWorkersForm({ config, onChange, overriddenByEnv, onValidityChange, resetToken }: SectionFormProps) {
const { t } = useTranslation('settings');
const llm: LlmConfigShape = config.llm ?? {};
const workers: LlmWorker[] = Array.isArray(llm.workers) ? llm.workers : [];
const retry = llm.retry ?? {};
// Stable per-row identity, independent of array position. `w.id` is a
// user-editable free-text field (can be empty/duplicated) so it isn't
// usable as a React key; array index breaks the moment a row above the
// one you're mid-editing is removed/reordered, because React then
// reuses that DOM/component instance for a *different* worker — the
// in-progress ExtraBodyField draft/validity (keyed by index) silently
// migrates to the wrong row. `uidsRef` mirrors the `workers` array
// 1:1 by position; addWorker/removeWorker/moveWorker mutate it in the
// same lockstep as their `onChange('llm.workers', ...)` call so a row's
// uid follows it across reorders and survives removal of *other* rows.
//
// The top-of-render check below only handles the array drifting out
// from under us for reasons other than the three handlers below (e.g.
// Discard Changes reverting a structural edit made in another tab, or
// the initial mount): if the lengths disagree, we don't know which
// positions correspond to which old rows, so we just regenerate fresh
// uids for the current shape. Any in-progress ExtraBodyField draft on
// screen at that moment loses its identity and unmounts/remounts —
// acceptable, since unsaved invalid JSON was never going to reach
// config.yaml anyway, and the unmount fires ConfigForm's validity
// cleanup so Save can't stay wedged.
const uidCounterRef = useRef(0);
const uidsRef = useRef<string[]>([]);
const nextUid = () => `w-${uidCounterRef.current++}`;
if (uidsRef.current.length !== workers.length) {
uidsRef.current = workers.map(() => nextUid());
}
const uids = uidsRef.current;
const updateWorker = (index: number, patch: Partial<LlmWorker>) => {
const next = workers.map((w, i) => (i === index ? { ...w, ...patch } : w));
onChange('llm.workers', next);
};
const removeWorker = (index: number) => {
uidsRef.current = uidsRef.current.filter((_, i) => i !== index);
onChange('llm.workers', workers.filter((_, i) => i !== index));
};
const moveWorker = (index: number, delta: number) => {
const target = index + delta;
if (target < 0 || target >= workers.length) return;
const next = [...workers];
const [removed] = next.splice(index, 1);
next.splice(target, 0, removed);
const nextUids = [...uidsRef.current];
const [removedUid] = nextUids.splice(index, 1);
nextUids.splice(target, 0, removedUid);
uidsRef.current = nextUids;
onChange('llm.workers', next);
};
const addWorker = () => {
const next: LlmWorker = {
id: `worker-${workers.length + 1}`,
connectionType: 'direct',
endpoint: '',
enabled: true,
maxConcurrency: 1,
roles: [],
};
uidsRef.current = [...uidsRef.current, nextUid()];
onChange('llm.workers', [...workers, next]);
};
// Pre-compute self-loop verdicts once per render so we don't recompute
// URL parsing inside the row JSX. Endpoint-only dependency is enough:
// connection_type is checked at render site.
const selfLoopFlags = useMemo(
() => workers.map(w => detectSelfLoop(w.endpoint)),
[workers],
);
return (
<div className="space-y-5">
<div>
<h2 className="text-base font-semibold text-slate-800 mb-1">{t('llmWorkers.title')}</h2>
<p className="text-xs text-slate-500 leading-relaxed">
{t('llmWorkers.intro')}<br />
{t('llmWorkers.rolesHelp')}
</p>
</div>
<div className="space-y-3">
{workers.length === 0 && (
<div className="text-xs text-slate-500 border border-dashed border-slate-200 rounded p-4 text-center">
{t('llmWorkers.empty')}
</div>
)}
{workers.map((w, i) => {
const uid = uids[i];
const isGateway = w.connectionType === 'aao_gateway' || w.proxy === true;
const showSelfLoop = isGateway && selfLoopFlags[i];
const endpointOverridden = i === 0 && overriddenByEnv['llm.workers[0].endpoint'];
const modelOverridden = i === 0 && overriddenByEnv['llm.workers[0].model'];
return (
// resetToken is folded into the key so Discard Changes / a
// fresh config load-refetch force a full remount of every row.
// That resyncs ExtraBodyField's local textarea+error draft from
// the reverted `value` prop even when the prop is unchanged
// from what the field itself last emitted (the case its own
// value-diff re-sync guard can't detect on its own) — see
// SectionFormProps.resetToken. ConfigForm clears invalidKeys
// directly in the same state update, so this remount only
// needs to reconcile the visuals, not the validity Set.
<div key={`${uid}:${resetToken ?? 0}`} className="border border-slate-200 rounded-lg p-4 space-y-3 relative">
<div className="absolute top-2 right-2 flex gap-1">
<button
onClick={() => moveWorker(i, -1)}
disabled={i === 0}
title={t('llmWorkers.moveUp')}
className="text-slate-400 hover:text-slate-700 text-sm leading-none disabled:opacity-30 disabled:cursor-not-allowed px-1"
>
</button>
<button
onClick={() => moveWorker(i, 1)}
disabled={i === workers.length - 1}
title={t('llmWorkers.moveDown')}
className="text-slate-400 hover:text-slate-700 text-sm leading-none disabled:opacity-30 disabled:cursor-not-allowed px-1"
>
</button>
<button
onClick={() => removeWorker(i)}
title={t('llmWorkers.removeWorker')}
className="text-slate-400 hover:text-red-500 text-lg leading-none px-1"
>
&times;
</button>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<FieldLabel>ID</FieldLabel>
<FieldInput value={w.id ?? ''} onChange={v => updateWorker(i, { id: v })} />
</div>
<div>
<FieldLabel>{t('llmWorkers.connectionType')}</FieldLabel>
<select
value={w.connectionType ?? (w.proxy === true ? 'aao_gateway' : 'direct')}
onChange={e => {
const next = e.target.value as 'direct' | 'aao_gateway';
// Keep the legacy `proxy` flag in sync so an
// operator who downgrades to a v1 build doesn't
// lose the routing semantics.
updateWorker(i, {
connectionType: next,
proxy: next === 'aao_gateway' ? true : undefined,
});
}}
className="w-full h-8 px-2 text-[13px] border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-canvas"
>
<option value="direct">Direct (Ollama / vLLM / llama.cpp)</option>
<option value="aao_gateway">AAO Gateway</option>
</select>
</div>
<div className="col-span-2">
<FieldLabel>Endpoint</FieldLabel>
<FieldInput
value={w.endpoint ?? ''}
onChange={v => updateWorker(i, { endpoint: v })}
disabled={!!endpointOverridden}
disabledReason={t('llmWorkers.endpointOverride')}
placeholder={
isGateway
? 'http://gateway.example.com:9876/v1'
: 'http://localhost:11434/v1'
}
/>
{endpointOverridden && <EnvOverrideWarning />}
{showSelfLoop && (
<p className="text-2xs text-amber-700 dark:text-amber-300 bg-amber-50 dark:bg-amber-500/15 border border-amber-200 dark:border-amber-500/30 rounded px-2 py-1 mt-1">
{t('llmWorkers.selfLoopWarn')}
</p>
)}
</div>
<div className="col-span-2">
<FieldLabel>{isGateway ? t('llmWorkers.apiKeyRequired') : t('llmWorkers.apiKeyOptional')}</FieldLabel>
<SecretInput
rawValue={w.apiKey ?? ''}
onChange={v => updateWorker(i, { apiKey: v === '' ? '' : v })}
placeholder={isGateway ? 'sk-aao-...' : t('llmWorkers.apiKeyOptionalPlaceholder')}
/>
<HelpText>
{isGateway ? t('llmWorkers.apiKeyGatewayHelp') : t('llmWorkers.apiKeyDirectHelp')}
</HelpText>
</div>
<div className="col-span-2">
<FieldLabel>Model</FieldLabel>
<ModelSelect
value={w.model ?? ''}
onChange={v => updateWorker(i, { model: v || undefined })}
endpoint={w.endpoint}
apiKeyRaw={w.apiKey}
/>
{modelOverridden && <EnvOverrideWarning />}
<HelpText>
{t('llmWorkers.modelHelp')}
</HelpText>
</div>
<div className="col-span-2">
<FieldLabel>Roles</FieldLabel>
<StringArrayEditor
value={Array.isArray(w.roles) ? w.roles : []}
onChange={roles => updateWorker(i, { roles })}
placeholder="auto / fast / quality / reflection / title"
/>
</div>
<div>
<FieldLabel>{t('llmWorkers.maxConcurrency')}</FieldLabel>
<FieldInput
type="number"
value={w.maxConcurrency ?? 1}
onChange={v => updateWorker(i, { maxConcurrency: Number(v) })}
/>
</div>
<div>
<FieldLabel>{t('llmWorkers.healthcheckInterval')}</FieldLabel>
<FieldInput
type="number"
value={w.healthcheckIntervalSeconds ?? ''}
onChange={v => updateWorker(i, { healthcheckIntervalSeconds: v === '' ? undefined : Number(v) })}
/>
<HelpText>{t('llmWorkers.healthcheckIntervalHelp')}</HelpText>
</div>
<div className="flex items-center gap-5 pt-5 flex-wrap">
<label className="flex items-center gap-2 text-sm text-slate-600 cursor-pointer">
<input
type="checkbox"
checked={w.enabled !== false}
onChange={e => updateWorker(i, { enabled: e.target.checked })}
className="rounded"
/>
{t('llmWorkers.enabled')}
</label>
<label
className="flex items-center gap-2 text-sm text-slate-600 cursor-pointer"
title={t('llmWorkers.vlmTitle')}
>
<input
type="checkbox"
checked={w.vlm === true}
onChange={e => updateWorker(i, { vlm: e.target.checked || undefined })}
className="rounded"
/>
VLM
</label>
<label
className="flex items-center gap-2 text-sm text-slate-600 cursor-pointer"
title={t('llmWorkers.returnProgressTitle')}
>
<input
type="checkbox"
checked={w.returnProgress === true}
onChange={e => updateWorker(i, { returnProgress: e.target.checked || undefined })}
className="rounded"
/>
{t('llmWorkers.returnProgress')}
</label>
</div>
<div className="col-span-2 border-t border-slate-100 pt-3 mt-1">
<p className="text-2xs font-medium text-slate-500 mb-2">{t('llmWorkers.advancedTitle')}</p>
</div>
<div className="col-span-2">
<FieldLabel>reasoning_efforts</FieldLabel>
<FieldInput
aria-label="reasoning_efforts"
value={Array.isArray(w.reasoningEfforts) ? w.reasoningEfforts.join(', ') : ''}
onChange={v => {
const efforts = v.split(',').map(s => s.trim()).filter(s => s.length > 0);
updateWorker(i, { reasoningEfforts: efforts.length > 0 ? efforts : undefined });
}}
placeholder="high, medium, low"
/>
<HelpText>{t('llmWorkers.reasoningEffortsHelp')}</HelpText>
</div>
<div>
<FieldLabel>reasoning_effort_mode</FieldLabel>
<select
aria-label="reasoning_effort_mode"
value={w.reasoningEffortMode ?? ''}
onChange={e => {
const next = e.target.value;
updateWorker(i, {
reasoningEffortMode: next === '' ? undefined : (next as 'body' | 'chat_template_kwargs'),
});
}}
className="w-full h-8 px-2 text-[13px] border border-hairline rounded-md focus:ring-2 focus:ring-accent-ring focus:border-accent outline-none bg-canvas"
>
<option value="">{t('llmWorkers.reasoningEffortModeDefault')}</option>
<option value="body">body</option>
<option value="chat_template_kwargs">chat_template_kwargs</option>
</select>
<HelpText>{t('llmWorkers.reasoningEffortModeHelp')}</HelpText>
</div>
<ExtraBodyField
value={w.extraBody}
onChange={extraBody => updateWorker(i, { extraBody })}
fieldKey={`llm.workers.${uid}.extraBody`}
onValidityChange={onValidityChange}
/>
</div>
</div>
);
})}
<button
onClick={addWorker}
className="px-4 py-2 text-sm text-accent border border-accent rounded-lg hover:bg-accent-soft"
>
{t('llmWorkers.addWorker')}
</button>
</div>
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">
{t('llmWorkers.globalTitle')}
</h3>
<div>
<FieldLabel htmlFor={settingsFieldId('llm.timeoutMinutes')}>Timeout (minutes)</FieldLabel>
<FieldInput
id={settingsFieldId('llm.timeoutMinutes')}
type="number"
value={llm.timeoutMinutes ?? 10}
onChange={v => onChange('llm.timeoutMinutes', Number(v))}
/>
<HelpText>{t('llmWorkers.timeoutHelp')}</HelpText>
</div>
<div>
<FieldLabel htmlFor={settingsFieldId('llm.maxStreamMinutes')}>Max Stream (minutes)</FieldLabel>
<FieldInput
id={settingsFieldId('llm.maxStreamMinutes')}
type="number"
value={llm.maxStreamMinutes ?? ''}
onChange={v => onChange('llm.maxStreamMinutes', v === '' ? undefined : Number(v))}
/>
<HelpText>{t('llmWorkers.maxStreamHelp')}</HelpText>
</div>
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">
{t('llmWorkers.retryTitle')}
</h3>
<div>
<FieldLabel>Max Attempts</FieldLabel>
<FieldInput
type="number"
value={retry.maxAttempts ?? 3}
onChange={v => onChange('llm.retry.maxAttempts', Number(v))}
/>
<HelpText>{t('llmWorkers.maxAttemptsHelp')}</HelpText>
</div>
<div>
<FieldLabel>Backoff (ms)</FieldLabel>
<StringArrayEditor
value={Array.isArray(retry.backoffMs) ? retry.backoffMs.map(n => String(n)) : []}
onChange={vs => {
const nums = vs.map(s => Number(s.trim())).filter(n => !isNaN(n));
onChange('llm.retry.backoffMs', nums);
}}
placeholder="2000"
/>
<HelpText>{t('llmWorkers.backoffHelp')}</HelpText>
</div>
<div>
<FieldLabel>Retryable Status Codes</FieldLabel>
<StringArrayEditor
value={Array.isArray(retry.retryableStatus) ? retry.retryableStatus.map(n => String(n)) : []}
onChange={vs => {
const nums = vs.map(s => Number(s.trim())).filter(n => !isNaN(n));
onChange('llm.retry.retryableStatus', nums);
}}
placeholder="429"
/>
<HelpText>{t('llmWorkers.retryableStatusHelp')}</HelpText>
</div>
</div>
);
}