feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,392 @@
|
||||
import { useMemo } from 'react';
|
||||
import { HelpText } from './HelpText';
|
||||
import { EnvOverrideWarning, FieldLabel, FieldInput } from './formUtils';
|
||||
import { SecretInput } from './SecretInput';
|
||||
import { ModelSelect } from './ModelSelect';
|
||||
import { StringArrayEditor } from './StringArrayEditor';
|
||||
import 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;
|
||||
/**
|
||||
* 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;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 }: SectionFormProps) {
|
||||
const llm: LlmConfigShape = config.llm ?? {};
|
||||
const workers: LlmWorker[] = Array.isArray(llm.workers) ? llm.workers : [];
|
||||
const retry = llm.retry ?? {};
|
||||
|
||||
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) => {
|
||||
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);
|
||||
onChange('llm.workers', next);
|
||||
};
|
||||
|
||||
const addWorker = () => {
|
||||
const next: LlmWorker = {
|
||||
id: `worker-${workers.length + 1}`,
|
||||
connectionType: 'direct',
|
||||
endpoint: '',
|
||||
enabled: true,
|
||||
maxConcurrency: 1,
|
||||
roles: [],
|
||||
};
|
||||
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">LLM Workers</h2>
|
||||
<p className="text-xs text-slate-500 leading-relaxed">
|
||||
このセクションは AAO がジョブ実行で <strong>呼び出す</strong> LLM 接続先 (workers)
|
||||
を定義します。AAO 自身を gateway として公開する設定は <em>LLM → Gateway Server</em>
|
||||
にあります。<br />
|
||||
ロール: <code>auto</code> (全 job 候補) / <code>fast</code> · <code>quality</code>
|
||||
(パフォーマンス profile) / <code>reflection</code> (reflection 専用) /{' '}
|
||||
<code>title</code> (タイトル生成専用)。複数指定可。
|
||||
</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">
|
||||
worker が未登録です。最低 1 つ追加してください。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{workers.map((w, 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">
|
||||
<div className="absolute top-2 right-2 flex gap-1">
|
||||
<button
|
||||
onClick={() => moveWorker(i, -1)}
|
||||
disabled={i === 0}
|
||||
title="上に移動"
|
||||
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="下に移動"
|
||||
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="この worker を削除"
|
||||
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>Connection type</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-white"
|
||||
>
|
||||
<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="OLLAMA_BASE_URL 環境変数で上書き中"
|
||||
placeholder={
|
||||
isGateway
|
||||
? 'http://gateway.example.com:9876/v1'
|
||||
: 'http://localhost:11434/v1'
|
||||
}
|
||||
/>
|
||||
{endpointOverridden && <EnvOverrideWarning />}
|
||||
{showSelfLoop && (
|
||||
<p className="text-2xs text-amber-700 bg-amber-50 border border-amber-200 rounded px-2 py-1 mt-1">
|
||||
endpoint は自インスタンスを指しているように見えます (self-loop)。
|
||||
リバースプロキシ越しの場合はこの警告は無視できます。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="col-span-2">
|
||||
<FieldLabel>API key{isGateway ? ' (必須)' : ' (任意)'}</FieldLabel>
|
||||
<SecretInput
|
||||
rawValue={w.apiKey ?? ''}
|
||||
onChange={v => updateWorker(i, { apiKey: v === '' ? '' : v })}
|
||||
placeholder={isGateway ? 'sk-aao-...' : 'sk-... (任意)'}
|
||||
/>
|
||||
<HelpText>
|
||||
{isGateway ? (
|
||||
<>
|
||||
他 AAO の <em>LLM → Gateway Server</em> で発行した{' '}
|
||||
<code>sk-aao-*</code> を貼り付けてください。
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
Bearer 認証が必要な場合のみ設定。Ollama 単体なら空のままで OK。
|
||||
</>
|
||||
)}
|
||||
</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>
|
||||
endpoint が <code>/models</code> を返せば dropdown に候補が出ます。
|
||||
出ない場合 (auth が必要、proxy 越し等) は直接入力してください。
|
||||
</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>最大同時実行数</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={w.maxConcurrency ?? 1}
|
||||
onChange={v => updateWorker(i, { maxConcurrency: Number(v) })}
|
||||
/>
|
||||
</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"
|
||||
/>
|
||||
有効
|
||||
</label>
|
||||
<label
|
||||
className="flex items-center gap-2 text-sm text-slate-600 cursor-pointer"
|
||||
title="VLM 対応モデルの場合、ReadImage が worker 自身のモデルを使用"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={w.vlm === true}
|
||||
onChange={e => updateWorker(i, { vlm: e.target.checked || undefined })}
|
||||
className="rounded"
|
||||
/>
|
||||
VLM
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
<button
|
||||
onClick={addWorker}
|
||||
className="px-4 py-2 text-sm text-accent border border-accent rounded-lg hover:bg-accent-soft"
|
||||
>
|
||||
+ Worker を追加
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">
|
||||
Global LLM Settings
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Timeout (minutes)</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={llm.timeoutMinutes ?? 10}
|
||||
onChange={v => onChange('llm.timeoutMinutes', Number(v))}
|
||||
/>
|
||||
<HelpText>LLM リクエストのタイムアウト (分)。デフォルト: 10</HelpText>
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm font-medium text-slate-600 mt-4 pt-3 border-t border-slate-200">
|
||||
Retry (per-call HTTP)
|
||||
</h3>
|
||||
|
||||
<div>
|
||||
<FieldLabel>Max Attempts</FieldLabel>
|
||||
<FieldInput
|
||||
type="number"
|
||||
value={retry.maxAttempts ?? 3}
|
||||
onChange={v => onChange('llm.retry.maxAttempts', Number(v))}
|
||||
/>
|
||||
<HelpText>1 回の LLM API 呼び出しでの最大試行回数</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>各リトライ間の待機時間 (ms)。配列順に消費されます。</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>リトライ対象の HTTP ステータスコード。</HelpText>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user