feat: initial public release (MAESTRO)

This commit is contained in:
oss-sync
2026-06-03 05:08:00 +00:00
commit f5c7666f6b
823 changed files with 184150 additions and 0 deletions
@@ -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"
>
&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>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>
);
}