625 lines
26 KiB
TypeScript
625 lines
26 KiB
TypeScript
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_effort(vLLM 向け)、chat_template_kwargs=
|
||
* chat_template_kwargs.reasoning_effort(llama-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"
|
||
>
|
||
×
|
||
</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>
|
||
);
|
||
}
|