Files
NeoTachyon/fe-app-tachyon/src/tachyon/services/store.service.ts
T
Dushnilin 6dd085b777 feat(ai-doctor): supercharge AI Doctor with multi-fix, log context, history, and 13 quick fix codes
Backend (runtime.uc, agent_api.uc, tachyon CLI):
- Enrich AI Doctor LLM context with Watchdog state (OOM, streaks, active repairs) and last 15 critical log lines
- Support multi-fix parsing (quick_fixes array) and comma-separated FIX tags
- Add persistent history saving to /tmp/ai_doctor_last.json and ai_doctor_last endpoint
- Expand quick fix codes from 9 to 13 (added clear_dns_cache, update_subscriptions, reset_firewall, restart_network)
- Implement apply_quick_fix CLI command and REST API endpoint

Frontend (fe-app-tachyon, LuCI main.js):
- Add AI Doctor action button in Diagnostics tab
- Add shell methods aiDoctor, aiDoctorLast, applyQuickFix
- Render AI Doctor modal with Multi-Fix buttons and 'Fix All' option
- Integrate toast notifications and state updates
2026-08-11 16:06:19 +03:00

295 lines
7.4 KiB
TypeScript

import { Tachyon } from '../types';
import { initialDiagnosticStore } from '../tabs/diagnostic/diagnostic.store';
function jsonStableStringify<T, V>(obj: T): string {
return JSON.stringify(obj, (_, value) => {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return Object.keys(value)
.sort()
.reduce(
(acc, key) => {
acc[key] = value[key];
return acc;
},
{} as Record<string, V>,
);
}
return value;
});
}
function jsonEqual<A, B>(a: A, b: B): boolean {
try {
return jsonStableStringify(a) === jsonStableStringify(b);
} catch {
return false;
}
}
type Listener<T> = (next: T, prev: T, diff: Partial<T>) => void;
// eslint-disable-next-line
export class StoreService<T extends Record<string, any>> {
private value: T;
private readonly initial: T;
private listeners = new Set<Listener<T>>();
constructor(initial: T) {
this.value = initial;
this.initial = structuredClone(initial);
}
get(): T {
return this.value;
}
set(next: Partial<T>): void {
const prev = this.value;
const diff: Partial<T> = {};
for (const key in next) {
if (!jsonEqual(next[key], prev[key])) diff[key] = next[key];
}
if (Object.keys(diff).length === 0) {
return;
}
const merged = { ...prev, ...next };
this.value = merged;
this.listeners.forEach((cb) => cb(this.value, prev, diff));
}
reset<K extends keyof T>(keys?: K[]): void {
const prev = this.value;
const next = structuredClone(this.value);
if (keys && keys.length > 0) {
keys.forEach((key) => {
next[key] = structuredClone(this.initial[key]);
});
} else {
Object.assign(next, structuredClone(this.initial));
}
if (jsonEqual(prev, next)) return;
this.value = next;
const diff: Partial<T> = {};
for (const key in next) {
if (!jsonEqual(next[key], prev[key])) diff[key] = next[key];
}
this.listeners.forEach((cb) => cb(this.value, prev, diff));
}
subscribe(cb: Listener<T>): () => void {
this.listeners.add(cb);
cb(this.value, this.value, {});
return () => this.listeners.delete(cb);
}
unsubscribe(cb: Listener<T>): void {
this.listeners.delete(cb);
}
patch<K extends keyof T>(key: K, value: T[K]): void {
this.set({ [key]: value } as unknown as Partial<T>);
}
getKey<K extends keyof T>(key: K): T[K] {
return this.value[key];
}
subscribeKey<K extends keyof T>(
key: K,
cb: (value: T[K]) => void,
): () => void {
let prev = this.value[key];
const wrapper: Listener<T> = (val) => {
if (!jsonEqual(val[key], prev)) {
prev = val[key];
cb(val[key]);
}
};
this.listeners.add(wrapper);
return () => this.listeners.delete(wrapper);
}
}
export interface IDiagnosticsChecksItem {
state: 'error' | 'warning' | 'success';
key: string;
value: string;
}
export interface IDiagnosticsChecksStoreItem {
order: number;
code: string;
title: string;
description: string;
state: 'loading' | 'warning' | 'success' | 'error' | 'skipped';
items: Array<IDiagnosticsChecksItem>;
}
export interface StoreType {
tabService: {
current: string;
all: string[];
};
bandwidthWidget: {
loading: boolean;
failed: boolean;
data: { up: number; down: number };
};
trafficTotalWidget: {
loading: boolean;
failed: boolean;
data: { downloadTotal: number; uploadTotal: number };
};
systemInfoWidget: {
loading: boolean;
failed: boolean;
data: { connections: number; memory: number };
};
servicesInfoWidget: {
loading: boolean;
failed: boolean;
data: {
singbox: number;
tachyonRunning: number;
tachyonEnabled: number;
tachyonStatus: string;
watchdogRunning: number;
};
};
sectionsWidget: {
loading: boolean;
failed: boolean;
data: Tachyon.OutboundGroup[];
latencyFetchingSections: Record<string, boolean>;
latencyProgressSections: Record<string, Tachyon.LatencyActionProgress>;
selectorSwitchingSections: Record<string, string>;
subscriptionUpdatingSections: Record<string, boolean>;
};
diagnosticsRunAction: {
loading: boolean;
};
diagnosticsChecks: Array<IDiagnosticsChecksStoreItem>;
diagnosticsActions: {
restart: { loading: boolean };
start: { loading: boolean };
stop: { loading: boolean };
enable: { loading: boolean };
disable: { loading: boolean };
globalCheck: { loading: boolean };
doctor: { loading: boolean };
aiDoctor: { loading: boolean };
viewLogs: { loading: boolean };
showSingBoxConfig: { loading: boolean };
generateBugReport: { loading: boolean };
};
diagnosticsSystemInfo: {
loading: boolean;
loaded: boolean;
providerInfoLoaded: boolean;
tachyon_version: string;
tachyon_commit_sha: string;
tachyon_latest_version: string;
luci_app_version: string;
sing_box_version: string;
sing_box_extended: number;
sing_box_tiny: number;
sing_box_compressed: number;
sing_box_lx: number;
sing_box_tailscale: number;
sing_box_repo_url?: string;
zapret_version: string;
zapret_installed: number;
zapret2_version: string;
zapret2_installed: number;
byedpi_version: string;
byedpi_installed: number;
server_inbounds_enabled_count: number;
openwrt_version: string;
device_model: string;
};
updatesActions: {
tachyonCheck: { loading: boolean };
tachyonInstall: { loading: boolean };
tachyonReinstall: { loading: boolean };
singBoxCheck: { loading: boolean };
singBoxInstall: { loading: boolean };
singBoxInstallExtended: { loading: boolean };
singBoxInstallExtendedCompressed: { loading: boolean };
singBoxInstallLx: { loading: boolean };
singBoxInstallTiny: { loading: boolean };
singBoxInstallStable: { loading: boolean };
zapretCheck: { loading: boolean };
zapretInstall: { loading: boolean };
zapretRemove: { loading: boolean };
zapret2Check: { loading: boolean };
zapret2Install: { loading: boolean };
zapret2Remove: { loading: boolean };
byedpiCheck: { loading: boolean };
byedpiInstall: { loading: boolean };
byedpiRemove: { loading: boolean };
};
updatesChecks: Record<
Tachyon.ComponentName,
{
status: 'latest' | 'outdated' | 'dev' | 'outdated_same_release' | null;
latest_version: string;
release_url: string;
current_sha?: string;
latest_sha?: string;
}
>;
}
const initialStore: StoreType = {
tabService: {
current: '',
all: [],
},
bandwidthWidget: {
loading: true,
failed: false,
data: { up: 0, down: 0 },
},
trafficTotalWidget: {
loading: true,
failed: false,
data: { downloadTotal: 0, uploadTotal: 0 },
},
systemInfoWidget: {
loading: true,
failed: false,
data: { connections: 0, memory: 0 },
},
servicesInfoWidget: {
loading: true,
failed: false,
data: {
singbox: 0,
tachyonRunning: 0,
tachyonEnabled: 0,
tachyonStatus: '',
watchdogRunning: 0,
},
},
sectionsWidget: {
loading: true,
failed: false,
latencyFetchingSections: {},
latencyProgressSections: {},
selectorSwitchingSections: {},
subscriptionUpdatingSections: {},
data: [],
},
...initialDiagnosticStore,
};
export const store = new StoreService<StoreType>(initialStore);