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; /** 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 | undefined; onChange: (v: Record | 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(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; lastEmitted.current = obj; onChange(obj); }; return (
extra_body