Changes: - drizzle/schema.ts: added llmProviders table (AES-256-GCM encrypted API keys) - drizzle/0004_llm_providers.sql: migration for llmProviders - server/providers.ts: full CRUD + AES-256-GCM encrypt/decrypt + seedDefaultProvider - server/routers.ts: replaced hardcoded config.providers with DB-backed providers router; added providers.list/create/update/delete/activate tRPC endpoints - server/seed.ts: calls seedDefaultProvider() on startup to seed from env if table empty - server/_core/index.ts: added POST /api/orchestrator/stream SSE proxy route to Go Gateway - gateway/internal/llm/client.go: added ChatStream (SSE) + UpdateCredentials - gateway/internal/orchestrator/orchestrator.go: added ChatWithEvents (tool-call callbacks) - gateway/internal/api/handlers.go: added OrchestratorStream (SSE) + ProvidersReload endpoints - gateway/internal/db/db.go: added GetActiveProvider from llmProviders table - gateway/cmd/gateway/main.go: registered /api/orchestrator/stream + /api/providers/reload routes - client/src/pages/Chat.tsx: full rebuild — 3-panel layout (left: conversation list, centre: messages with SSE streaming + markdown, right: live tool-call console) - client/src/pages/Settings.tsx: full rebuild — DB-backed provider CRUD (add/edit/activate/delete), no hardcoded keys, key shown masked from DB hint
126 lines
3.8 KiB
TypeScript
126 lines
3.8 KiB
TypeScript
import "dotenv/config";
|
|
import express from "express";
|
|
import { createServer } from "http";
|
|
import net from "net";
|
|
import { createExpressMiddleware } from "@trpc/server/adapters/express";
|
|
import { registerOAuthRoutes } from "./oauth";
|
|
import { appRouter } from "../routers";
|
|
import { createContext } from "./context";
|
|
import { serveStatic, setupVite } from "./vite";
|
|
import { seedDefaults } from "../seed";
|
|
|
|
const GATEWAY_BASE_URL = process.env.GATEWAY_URL ?? "http://localhost:18789";
|
|
|
|
function isPortAvailable(port: number): Promise<boolean> {
|
|
return new Promise(resolve => {
|
|
const server = net.createServer();
|
|
server.listen(port, () => {
|
|
server.close(() => resolve(true));
|
|
});
|
|
server.on("error", () => resolve(false));
|
|
});
|
|
}
|
|
|
|
async function findAvailablePort(startPort: number = 3000): Promise<number> {
|
|
for (let port = startPort; port < startPort + 20; port++) {
|
|
if (await isPortAvailable(port)) {
|
|
return port;
|
|
}
|
|
}
|
|
throw new Error(`No available port found starting from ${startPort}`);
|
|
}
|
|
|
|
async function startServer() {
|
|
const app = express();
|
|
const server = createServer(app);
|
|
// Configure body parser with larger size limit for file uploads
|
|
app.use(express.json({ limit: "50mb" }));
|
|
app.use(express.urlencoded({ limit: "50mb", extended: true }));
|
|
// OAuth callback under /api/oauth/callback
|
|
registerOAuthRoutes(app);
|
|
|
|
// ── SSE proxy: POST /api/orchestrator/stream → Go Gateway SSE ──────────────
|
|
// This proxies the SSE stream from the Go Gateway to the browser.
|
|
// We need to do it at the Express level because tRPC doesn't support SSE yet.
|
|
app.post("/api/orchestrator/stream", async (req, res) => {
|
|
try {
|
|
const gwRes = await fetch(`${GATEWAY_BASE_URL}/api/orchestrator/stream`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(req.body),
|
|
});
|
|
|
|
if (!gwRes.ok || !gwRes.body) {
|
|
res.status(gwRes.status).json({ error: "Gateway stream error" });
|
|
return;
|
|
}
|
|
|
|
// Set SSE headers
|
|
res.setHeader("Content-Type", "text/event-stream");
|
|
res.setHeader("Cache-Control", "no-cache");
|
|
res.setHeader("Connection", "keep-alive");
|
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
res.flushHeaders();
|
|
|
|
// Pipe the response body
|
|
const reader = gwRes.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
|
|
const pump = async () => {
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
res.write(decoder.decode(value, { stream: true }));
|
|
// @ts-ignore
|
|
if (res.flush) (res as any).flush();
|
|
}
|
|
} catch {
|
|
// Client disconnected
|
|
} finally {
|
|
res.end();
|
|
}
|
|
};
|
|
|
|
// Abort if client disconnects
|
|
req.on("close", () => reader.cancel());
|
|
await pump();
|
|
} catch (err: any) {
|
|
if (!res.headersSent) {
|
|
res.status(502).json({ error: `Gateway unreachable: ${err.message}` });
|
|
}
|
|
}
|
|
});
|
|
|
|
// tRPC API
|
|
app.use(
|
|
"/api/trpc",
|
|
createExpressMiddleware({
|
|
router: appRouter,
|
|
createContext,
|
|
})
|
|
);
|
|
// development mode uses Vite, production mode uses static files
|
|
if (process.env.NODE_ENV === "development") {
|
|
await setupVite(app, server);
|
|
} else {
|
|
serveStatic(app);
|
|
}
|
|
|
|
const preferredPort = parseInt(process.env.PORT || "3000");
|
|
const port = await findAvailablePort(preferredPort);
|
|
|
|
if (port !== preferredPort) {
|
|
console.log(`Port ${preferredPort} is busy, using port ${port} instead`);
|
|
}
|
|
|
|
// Run idempotent seed on every startup (no-op if data already exists)
|
|
await seedDefaults();
|
|
|
|
server.listen(port, () => {
|
|
console.log(`Server running on http://localhost:${port}/`);
|
|
});
|
|
}
|
|
|
|
startServer().catch(console.error);
|