АРЧ - UI и domain loop: синхронизировать локальную LLM-модель через shared config + КВИН 3

This commit is contained in:
2026-04-15 09:15:35 +03:00
parent bc381c012e
commit 8866176be6
24 changed files with 801 additions and 73 deletions
+67 -15
View File
@@ -153,6 +153,7 @@ export default function App() {
});
const presetAutoloadDoneRef = useRef(false);
const skipPresetAutoloadRef = useRef(false);
const sharedConnectionSyncReadyRef = useRef(false);
useEffect(() => {
const root = document.documentElement;
@@ -192,22 +193,45 @@ export default function App() {
}
useEffect(() => {
const cached = localStorage.getItem(SESSION_CONFIG_KEY);
if (cached) {
try {
const parsed = JSON.parse(cached) as Partial<ConnectionState>;
setConnection((prev) => ({
...prev,
llmProvider: parsed.llmProvider === "local" ? "local" : "openai",
model: parsed.model ?? prev.model,
baseUrl: parsed.baseUrl ?? prev.baseUrl,
temperature: parsed.temperature ?? prev.temperature,
maxOutputTokens: parsed.maxOutputTokens ?? prev.maxOutputTokens
}));
} catch {
// ignore broken local cache
const bootstrapSharedConnection = async () => {
const cached = localStorage.getItem(SESSION_CONFIG_KEY);
if (cached) {
try {
const parsed = JSON.parse(cached) as Partial<ConnectionState>;
setConnection((prev) => ({
...prev,
llmProvider: parsed.llmProvider === "local" ? "local" : "openai",
model: parsed.model ?? prev.model,
baseUrl: parsed.baseUrl ?? prev.baseUrl,
temperature: parsed.temperature ?? prev.temperature,
maxOutputTokens: parsed.maxOutputTokens ?? prev.maxOutputTokens
}));
} catch {
// ignore broken local cache
}
}
}
try {
const payload = await apiClient.loadSharedConnectionConfig();
if (payload.connection && payload.connection.llmProvider === "local") {
setConnection((prev) => ({
...prev,
llmProvider: "local",
model: payload.connection?.model ?? prev.model,
baseUrl: payload.connection?.baseUrl ?? prev.baseUrl,
temperature: payload.connection?.temperature ?? prev.temperature,
maxOutputTokens: payload.connection?.maxOutputTokens ?? prev.maxOutputTokens
}));
log(`Shared local LLM config loaded: ${payload.connection.model}`);
}
} catch (error) {
log(`Shared local config load error: ${error instanceof Error ? error.message : String(error)}`);
} finally {
sharedConnectionSyncReadyRef.current = true;
}
};
void bootstrapSharedConnection();
const cachedAutorunsLayout = localStorage.getItem(AUTORUNS_LAYOUT_CONFIG_KEY);
if (cachedAutorunsLayout) {
@@ -304,6 +328,23 @@ export default function App() {
void refreshRuns();
}, []);
useEffect(() => {
if (!sharedConnectionSyncReadyRef.current) {
return;
}
if (connection.llmProvider !== "local") {
return;
}
const timer = window.setTimeout(() => {
void apiClient
.saveSharedConnectionConfig(connection)
.catch((error) =>
log(`Shared local config sync error: ${error instanceof Error ? error.message : String(error)}`)
);
}, 250);
return () => window.clearTimeout(timer);
}, [connection.baseUrl, connection.llmProvider, connection.maxOutputTokens, connection.model, connection.temperature]);
async function refreshHistory() {
try {
const payload = await apiClient.loadHistory();
@@ -369,6 +410,17 @@ export default function App() {
maxOutputTokens: connection.maxOutputTokens
})
);
if (connection.llmProvider === "local") {
void apiClient
.saveSharedConnectionConfig(connection)
.then(() => {
log("Local config saved and synced to shared agent config (without API key).");
})
.catch((error) => {
log(`Local config saved, but shared sync failed: ${error instanceof Error ? error.message : String(error)}`);
});
return;
}
log("Local config saved (without API key).");
}
+26
View File
@@ -41,6 +41,32 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
}
export const apiClient = {
async loadSharedConnectionConfig(): Promise<{
ok: boolean;
connection: Omit<ConnectionState, "apiKey"> | null;
updated_at: string | null;
exists: boolean;
}> {
return request("/llm/shared-connection");
},
async saveSharedConnectionConfig(connection: ConnectionState): Promise<{
ok: boolean;
connection: Omit<ConnectionState, "apiKey">;
updated_at: string;
}> {
return request("/llm/shared-connection", {
method: "POST",
body: JSON.stringify({
llmProvider: connection.llmProvider,
model: connection.model,
baseUrl: connection.baseUrl,
temperature: connection.temperature,
maxOutputTokens: connection.maxOutputTokens
})
});
},
async listModels(connection: ConnectionState): Promise<{ ok: boolean; models: string[]; count: number; timestamp: string }> {
return request("/llm/models", {
method: "POST",
@@ -2,6 +2,57 @@ import { useEffect, useState } from "react";
import { PanelFrame } from "./PanelFrame";
import type { ConnectionState } from "../state/types";
const LOCAL_BASE_URL = "http://127.0.0.1:1234/v1";
const OPENAI_BASE_URL = "https://api.openai.com/v1";
const LOCAL_QWEN25_MODEL = "qwen2.5-14b-instruct-1m";
const LOCAL_QWEN3_MODEL = "unsloth/qwen3-30b-a3b-instruct-2507";
type ProviderPreset = "openai" | "local_qwen25" | "local_qwen3" | "local_custom";
interface ModelOptionDescriptor {
value: string;
label: string;
}
const BUILTIN_LOCAL_MODEL_OPTIONS: ModelOptionDescriptor[] = [
{
value: LOCAL_QWEN25_MODEL,
label: "Qwen2.5 14B Instruct 1M"
},
{
value: LOCAL_QWEN3_MODEL,
label: "Qwen3 30B A3B Instruct 2507"
}
];
function deriveProviderPreset(value: ConnectionState): ProviderPreset {
if (value.llmProvider !== "local") {
return "openai";
}
if (value.model === LOCAL_QWEN3_MODEL) {
return "local_qwen3";
}
if (value.model === LOCAL_QWEN25_MODEL) {
return "local_qwen25";
}
return "local_custom";
}
function buildModelOptions(modelOptions: string[], isLocal: boolean): ModelOptionDescriptor[] {
const merged = new Map<string, ModelOptionDescriptor>();
if (isLocal) {
for (const option of BUILTIN_LOCAL_MODEL_OPTIONS) {
merged.set(option.value, option);
}
}
for (const modelId of modelOptions) {
if (!merged.has(modelId)) {
merged.set(modelId, { value: modelId, label: modelId });
}
}
return Array.from(merged.values());
}
interface ConnectionPanelProps {
value: ConnectionState;
modelOptions: string[];
@@ -26,7 +77,9 @@ export function ConnectionPanel({
busy
}: ConnectionPanelProps) {
const isLocal = value.llmProvider === "local";
const modelInCatalog = modelOptions.includes(value.model);
const providerPreset = deriveProviderPreset(value);
const availableModelOptions = buildModelOptions(modelOptions, isLocal);
const modelInCatalog = availableModelOptions.some((option) => option.value === value.model);
const [temperatureInput, setTemperatureInput] = useState(String(value.temperature));
const [maxOutputTokensInput, setMaxOutputTokensInput] = useState(String(value.maxOutputTokens));
@@ -78,18 +131,47 @@ export function ConnectionPanel({
<label>
Provider
<select
value={value.llmProvider}
value={providerPreset}
onChange={(event) => {
const nextProvider = event.target.value === "local" ? "local" : "openai";
const selected = event.target.value as ProviderPreset;
if (selected === "openai") {
onChange({
...value,
llmProvider: "openai",
baseUrl: OPENAI_BASE_URL
});
return;
}
if (selected === "local_qwen25") {
onChange({
...value,
llmProvider: "local",
model: LOCAL_QWEN25_MODEL,
baseUrl: LOCAL_BASE_URL
});
return;
}
if (selected === "local_qwen3") {
onChange({
...value,
llmProvider: "local",
model: LOCAL_QWEN3_MODEL,
baseUrl: LOCAL_BASE_URL
});
return;
}
onChange({
...value,
llmProvider: nextProvider,
baseUrl: nextProvider === "local" ? "http://127.0.0.1:1234/v1" : "https://api.openai.com/v1"
llmProvider: "local",
model: value.llmProvider === "local" ? value.model : LOCAL_QWEN25_MODEL,
baseUrl: LOCAL_BASE_URL
});
}}
>
<option value="openai">OpenAI (token)</option>
<option value="local">Local (LM Studio / OpenAI-compatible)</option>
<option value="local_qwen25">Qwen2.5 14B Instruct 1M (Local LM Studio)</option>
<option value="local_qwen3">Qwen3 30B A3B Instruct 2507 (Local LM Studio)</option>
<option value="local_custom">Local custom (LM Studio / OpenAI-compatible)</option>
</select>
</label>
@@ -106,20 +188,20 @@ export function ConnectionPanel({
}}
>
<option value="__manual__">Manual input</option>
{modelOptions.map((modelId) => (
<option key={modelId} value={modelId}>
{modelId}
{availableModelOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</label>
<label>
Model ID (manual)
Model ID (manual / current)
<input
value={value.model}
onChange={(event) => onChange({ ...value, model: event.target.value })}
placeholder="qwen2.5-14b-instruct or lmstudio loaded model id"
placeholder="qwen2.5-14b-instruct-1m or unsloth/qwen3-30b-a3b-instruct-2507"
/>
</label>
@@ -140,7 +222,7 @@ export function ConnectionPanel({
<input
value={value.baseUrl}
onChange={(event) => onChange({ ...value, baseUrl: event.target.value })}
placeholder={isLocal ? "http://127.0.0.1:1234/v1" : "https://api.openai.com/v1"}
placeholder={isLocal ? LOCAL_BASE_URL : OPENAI_BASE_URL}
/>
</label>