- Chat.tsx: rewritten to use global chatStore singleton — SSE connection survives
page navigation; added StopCircle cancel button; scrolls only when near bottom
- chatStore.ts: new module-level singleton (EventTarget pattern) that holds all
conversation/console state; TextDecoder with stream:true for correct UTF-8
- handlers.go (ProvidersReload): now accepts decrypted key in request body from
Node.js so Go gateway can actually use the API key without sharing crypto logic
- providers.ts (activateProvider): sends decrypted key to gateway via
notifyGatewayReload(); seedDefaultProvider also calls notifyGatewayReload()
- seed.ts: on startup, after seeding, pushes active provider to gateway with
retry loop (5 retries × 3 s) to wait for gateway readiness
- index.ts (SSE proxy): TextDecoder('utf-8', {stream:true}) already correct;
confirmed Cyrillic text arrives ungarbled (e.g. 'Привет!' not '??????????')
250 lines
8.2 KiB
TypeScript
250 lines
8.2 KiB
TypeScript
/**
|
||
* LLM Providers — CRUD + шифрование API-ключей + синхронизация с Go Gateway.
|
||
*
|
||
* Ключи шифруются AES-256-GCM с помощью JWT_SECRET.
|
||
* Gateway перечитывает активного провайдера через GET /api/providers/active
|
||
* при каждом запросе (или кэширует на 30 сек).
|
||
*/
|
||
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "crypto";
|
||
import { getDb } from "./db";
|
||
import { llmProviders, type LlmProvider, type InsertLlmProvider } from "../drizzle/schema";
|
||
import { eq, and } from "drizzle-orm";
|
||
import { ENV } from "./_core/env";
|
||
|
||
// ─── Encryption helpers ───────────────────────────────────────────────────────
|
||
|
||
const ALGO = "aes-256-gcm";
|
||
|
||
function getDerivedKey(): Buffer {
|
||
const secret = ENV.cookieSecret || "goclaw-default-secret-change-me";
|
||
return scryptSync(secret, "goclaw-llm-salt", 32);
|
||
}
|
||
|
||
export function encryptKey(plaintext: string): string {
|
||
if (!plaintext) return "";
|
||
const iv = randomBytes(12);
|
||
const key = getDerivedKey();
|
||
const cipher = createCipheriv(ALGO, key, iv);
|
||
const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
|
||
const tag = cipher.getAuthTag();
|
||
// iv(12) + tag(16) + ciphertext — base64 encoded
|
||
return Buffer.concat([iv, tag, encrypted]).toString("base64");
|
||
}
|
||
|
||
export function decryptKey(encoded: string): string {
|
||
if (!encoded) return "";
|
||
try {
|
||
const buf = Buffer.from(encoded, "base64");
|
||
const iv = buf.subarray(0, 12);
|
||
const tag = buf.subarray(12, 28);
|
||
const ciphertext = buf.subarray(28);
|
||
const key = getDerivedKey();
|
||
const decipher = createDecipheriv(ALGO, key, iv);
|
||
decipher.setAuthTag(tag);
|
||
return decipher.update(ciphertext) + decipher.final("utf8");
|
||
} catch {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
// ─── DB operations ────────────────────────────────────────────────────────────
|
||
|
||
/** Returns the currently active provider with decrypted key. */
|
||
export async function getActiveProvider(): Promise<{
|
||
id: number;
|
||
name: string;
|
||
baseUrl: string;
|
||
apiKey: string;
|
||
apiKeyHint: string;
|
||
modelDefault: string | null;
|
||
} | null> {
|
||
const db = await getDb();
|
||
if (!db) return null;
|
||
const rows = await db
|
||
.select()
|
||
.from(llmProviders)
|
||
.where(eq(llmProviders.isActive, true))
|
||
.limit(1);
|
||
if (!rows.length) return null;
|
||
const p = rows[0];
|
||
return {
|
||
id: p.id,
|
||
name: p.name,
|
||
baseUrl: p.baseUrl,
|
||
apiKey: decryptKey(p.apiKeyEncrypted ?? ""),
|
||
apiKeyHint: p.apiKeyHint ?? "",
|
||
modelDefault: p.modelDefault ?? null,
|
||
};
|
||
}
|
||
|
||
/** Returns all providers (keys masked). */
|
||
export async function listProviders(): Promise<Array<{
|
||
id: number;
|
||
name: string;
|
||
baseUrl: string;
|
||
apiKeyHint: string;
|
||
isActive: boolean;
|
||
isDefault: boolean;
|
||
modelDefault: string | null;
|
||
notes: string | null;
|
||
createdAt: Date;
|
||
updatedAt: Date;
|
||
}>> {
|
||
const db = await getDb();
|
||
if (!db) return [];
|
||
const rows = await db.select().from(llmProviders).orderBy(llmProviders.id);
|
||
return rows.map((p) => ({
|
||
id: p.id,
|
||
name: p.name,
|
||
baseUrl: p.baseUrl,
|
||
apiKeyHint: p.apiKeyHint ?? "",
|
||
isActive: p.isActive,
|
||
isDefault: p.isDefault,
|
||
modelDefault: p.modelDefault ?? null,
|
||
notes: p.notes ?? null,
|
||
createdAt: p.createdAt,
|
||
updatedAt: p.updatedAt,
|
||
}));
|
||
}
|
||
|
||
/** Creates a new provider. */
|
||
export async function createProvider(data: {
|
||
name: string;
|
||
baseUrl: string;
|
||
apiKey: string;
|
||
modelDefault?: string;
|
||
notes?: string;
|
||
setActive?: boolean;
|
||
}): Promise<number> {
|
||
const db = await getDb();
|
||
if (!db) throw new Error("DB not connected");
|
||
|
||
const encrypted = encryptKey(data.apiKey);
|
||
const hint = data.apiKey ? data.apiKey.slice(0, 8) : "";
|
||
|
||
// If setActive — deactivate all others first
|
||
if (data.setActive) {
|
||
await db.update(llmProviders).set({ isActive: false });
|
||
}
|
||
|
||
const [result] = await db.insert(llmProviders).values({
|
||
name: data.name,
|
||
baseUrl: data.baseUrl,
|
||
apiKeyEncrypted: encrypted,
|
||
apiKeyHint: hint,
|
||
isActive: data.setActive ?? false,
|
||
isDefault: data.setActive ?? false,
|
||
modelDefault: data.modelDefault ?? null,
|
||
notes: data.notes ?? null,
|
||
} as InsertLlmProvider);
|
||
|
||
return (result as any).insertId as number;
|
||
}
|
||
|
||
/** Updates a provider. Pass apiKey="" to keep existing. */
|
||
export async function updateProvider(
|
||
id: number,
|
||
data: {
|
||
name?: string;
|
||
baseUrl?: string;
|
||
apiKey?: string;
|
||
modelDefault?: string;
|
||
notes?: string;
|
||
isActive?: boolean;
|
||
}
|
||
): Promise<void> {
|
||
const db = await getDb();
|
||
if (!db) throw new Error("DB not connected");
|
||
|
||
const updates: Partial<LlmProvider> = {};
|
||
if (data.name !== undefined) updates.name = data.name;
|
||
if (data.baseUrl !== undefined) updates.baseUrl = data.baseUrl;
|
||
if (data.modelDefault !== undefined) updates.modelDefault = data.modelDefault;
|
||
if (data.notes !== undefined) updates.notes = data.notes;
|
||
|
||
if (data.apiKey !== undefined && data.apiKey !== "") {
|
||
updates.apiKeyEncrypted = encryptKey(data.apiKey);
|
||
updates.apiKeyHint = data.apiKey.slice(0, 8);
|
||
}
|
||
|
||
if (data.isActive !== undefined) {
|
||
// Deactivate all others first
|
||
if (data.isActive) {
|
||
await db.update(llmProviders).set({ isActive: false });
|
||
}
|
||
updates.isActive = data.isActive;
|
||
}
|
||
|
||
await db.update(llmProviders).set(updates as any).where(eq(llmProviders.id, id));
|
||
}
|
||
|
||
/** Deletes a provider. Cannot delete the active one. */
|
||
export async function deleteProvider(id: number): Promise<{ ok: boolean; error?: string }> {
|
||
const db = await getDb();
|
||
if (!db) return { ok: false, error: "DB not connected" };
|
||
|
||
const rows = await db.select().from(llmProviders).where(
|
||
and(eq(llmProviders.id, id), eq(llmProviders.isActive, true))
|
||
).limit(1);
|
||
if (rows.length > 0) {
|
||
return { ok: false, error: "Cannot delete the active provider. Set another as active first." };
|
||
}
|
||
await db.delete(llmProviders).where(eq(llmProviders.id, id));
|
||
return { ok: true };
|
||
}
|
||
|
||
/** Activates a provider and notifies Go Gateway to reload its config with the decrypted key. */
|
||
export async function activateProvider(id: number): Promise<void> {
|
||
await updateProvider(id, { isActive: true });
|
||
// Notify gateway to reload — pass decrypted key so Go can use it without sharing crypto logic
|
||
await notifyGatewayReload();
|
||
}
|
||
|
||
/**
|
||
* Reads the active provider (with decrypted key) and pushes it to the Go Gateway.
|
||
* Called after any activation/seed so the gateway always has a fresh key.
|
||
*/
|
||
export async function notifyGatewayReload(): Promise<void> {
|
||
try {
|
||
const provider = await getActiveProvider();
|
||
if (!provider) return;
|
||
const gwUrl = process.env.GATEWAY_URL || "http://goclaw-gateway:18789";
|
||
await fetch(`${gwUrl}/api/providers/reload`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
name: provider.name,
|
||
baseUrl: provider.baseUrl,
|
||
apiKey: provider.apiKey,
|
||
modelDefault: provider.modelDefault,
|
||
}),
|
||
});
|
||
console.log(`[Providers] Gateway reloaded with provider: ${provider.name}`);
|
||
} catch (err) {
|
||
console.warn("[Providers] Failed to notify gateway:", err);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Seeds the default provider from env vars (runs once on startup if table empty).
|
||
*/
|
||
export async function seedDefaultProvider(): Promise<void> {
|
||
const db = await getDb();
|
||
if (!db) return;
|
||
const existing = await db.select().from(llmProviders).limit(1);
|
||
if (existing.length > 0) return; // already seeded
|
||
|
||
const baseUrl = process.env.OLLAMA_BASE_URL || "https://ollama.com/v1";
|
||
const apiKey = process.env.OLLAMA_API_KEY || process.env.LLM_API_KEY || "";
|
||
|
||
let name = "Ollama Cloud";
|
||
if (baseUrl.includes("openai.com")) name = "OpenAI";
|
||
else if (baseUrl.includes("groq.com")) name = "Groq";
|
||
else if (baseUrl.includes("mistral.ai")) name = "Mistral";
|
||
|
||
await createProvider({ name, baseUrl, apiKey, setActive: true, modelDefault: "qwen2.5:7b" });
|
||
console.log(`[Providers] Seeded default provider: ${name} (${baseUrl})`);
|
||
// Push the active provider to the gateway immediately after seeding
|
||
await notifyGatewayReload();
|
||
}
|