This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { HelpText } from './HelpText';
|
||||
import { EnvOverrideWarning, FieldLabel, FieldInput } from './formUtils';
|
||||
@@ -27,6 +27,22 @@ interface LlmWorker {
|
||||
/** 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
|
||||
@@ -80,6 +96,100 @@ function detectSelfLoop(endpoint: string | undefined): boolean {
|
||||
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);
|
||||
// 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
|
||||
aria-label="extra_body"
|
||||
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 className="text-2xs text-red-600 mt-1">{error}</p>}
|
||||
<HelpText>{t('llmWorkers.extraBodyHelp')}</HelpText>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Settings → LLM → Workers.
|
||||
*
|
||||
@@ -103,18 +213,48 @@ function detectSelfLoop(endpoint: string | undefined): boolean {
|
||||
* - `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 }: SectionFormProps) {
|
||||
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));
|
||||
};
|
||||
|
||||
@@ -124,6 +264,12 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
|
||||
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);
|
||||
};
|
||||
|
||||
@@ -136,6 +282,7 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
|
||||
maxConcurrency: 1,
|
||||
roles: [],
|
||||
};
|
||||
uidsRef.current = [...uidsRef.current, nextUid()];
|
||||
onChange('llm.workers', [...workers, next]);
|
||||
};
|
||||
|
||||
@@ -165,12 +312,22 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
|
||||
)}
|
||||
|
||||
{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 (
|
||||
<div key={i} className="border border-slate-200 rounded-lg p-4 space-y-3 relative">
|
||||
// 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)}
|
||||
@@ -334,6 +491,51 @@ export function LlmWorkersForm({ config, onChange, overriddenByEnv }: SectionFor
|
||||
{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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user