Files
WeChat-AI/apps/api/src/index.ts
T

493 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import path from "node:path";
import { constants as zlibConstants } from "node:zlib";
import { fileURLToPath } from "node:url";
import Fastify from "fastify";
import compress from "@fastify/compress";
import { ChatService, TryChatService } from "@wechat-ai/core";
import { openDatabase, seedPersonas, setRedisCommandHook } from "@wechat-ai/db";
import { LlmClient } from "@wechat-ai/llm";
import { BotLoginSessionManager } from "./bot-login-sessions.js";
import {
CC_HTML_APP,
CC_HTML_MARKETING,
CC_OG,
CDN_HTML_APP,
CDN_HTML_MARKETING,
CDN_OG,
ifNoneMatchHits,
setPublicCache,
} from "./cache-headers.js";
import { initActivityBus } from "./activity-stream.js";
import { LOG_LEVELS, loadConfig } from "./config.js";
import { registerRoutes } from "./routes.js";
import {
buildFastifyOptions,
registerRequestLogging,
} from "./server-options.js";
import { RuntimeConfigManager } from "./runtime-config.js";
import {
applyRuntimeConfigToServices,
type RuntimeConfigTargets,
} from "./runtime-config-apply.js";
import {
loadStaticAssets,
pickEncoded,
upgradeStaticCompression,
} from "./static-pages.js";
import { BotWorkerManager } from "./worker.js";
import { loadLinuxDoConfig } from "./oauth-linuxdo.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// The bot worker runs in this same process/event loop. A stray rejection from
// any of its detached loops must not take the HTTP server down with it.
process.on("unhandledRejection", (reason) => {
console.error("[fatal] unhandled rejection (kept alive):", reason);
});
process.on("uncaughtException", (err) => {
console.error("[fatal] uncaught exception (kept alive):", err);
});
async function main(): Promise<void> {
const cfg = loadConfig();
console.log(`[config] repoRoot=${cfg.repoRoot}`);
console.log(`[config] redis=${cfg.redisUrl}`);
console.log(
`[config] stickers=redis blob (max ${cfg.stickerMaxBytes} bytes)`,
);
const db = openDatabase(cfg.redisUrl);
try {
await db.ping();
console.log("[redis] PONG");
} catch (err) {
console.error(
"[redis] 无法连接 REDIS_URL,请检查远端 Redis",
cfg.redisUrl,
err,
);
process.exit(1);
}
await seedPersonas(db);
// Redis-stored admin overrides on top of env. Loaded BEFORE any service is
// constructed so boot already uses the effective values; the fan-out target
// is filled in once the services exist, and the 5s poll starts after that.
let runtimeTargets: RuntimeConfigTargets | null = null;
const settings = new RuntimeConfigManager(db, cfg, (changed, live) => {
if (runtimeTargets) {
applyRuntimeConfigToServices(changed, live, runtimeTargets);
}
});
await settings.init();
{
const v = settings.view();
console.log(
`[settings] runtime overrides=${v.overriddenCount}/${v.items.length}` +
(v.updatedAt ? ` updatedAt=${v.updatedAt} by=${v.updatedBy}` : ""),
);
for (const w of settings.currentWarnings()) console.warn(`[settings] ${w}`);
}
const activityBus = initActivityBus({
db,
source: process.env.WORKER_ID?.trim() || "api",
enabled: cfg.dataStreamEnabled,
maxEps: cfg.dataStreamMaxEps,
redisSample: cfg.dataStreamRedisSample,
});
// Installed unconditionally: noteRedisCmd() no-ops while the bus is
// disabled, and the admin panel can turn DATA_STREAM_ENABLED on at runtime —
// a boot-time branch here would leave that switch permanently dead.
setRedisCommandHook((info) => activityBus.noteRedisCmd(info));
if (cfg.dataStreamEnabled) {
void activityBus.start().then(() => {
console.log(
`[stream] activity bus on sample=${cfg.dataStreamRedisSample} maxEps=${cfg.dataStreamMaxEps}`,
);
});
}
// Platform (admin) LLM: direct. User custom APIs + search: TOOLS gateway only.
const llm = LlmClient.forPlatform({
baseURL: cfg.llmBaseUrl,
apiKey: cfg.llmApiKey || "missing",
model: cfg.llmModel,
toolsBaseUrl: cfg.toolsBaseUrl || undefined,
toolsApiKey: cfg.toolsApiKey || undefined,
});
if (!cfg.llmApiKey) {
console.warn("[warn] LLM_API_KEY not set (platform / admin LLM)");
}
if (cfg.webSearchEnabled && !cfg.toolsBaseUrl) {
console.warn(
"[warn] WEB_SEARCH_ENABLED but TOOLS_BASE_URL empty — search will fail until HF tools is configured",
);
}
if (cfg.toolsBaseUrl) {
console.log(`[config] tools gateway=${cfg.toolsBaseUrl} (user custom LLM + search)`);
} else {
console.log(
"[config] TOOLS_BASE_URL not set — user custom LLM APIs and web search unavailable",
);
}
/**
* Vision endpoint for reading inbound images.
*
* Separate from the platform LLM on purpose: the roleplay model is usually
* text-only (deepseek et al), so caption mode sends the image to a
* vision-capable endpoint and passes only its text description onward.
* Base/key default to the platform LLM's, which covers providers that host a
* vision model alongside the chat model.
*/
const visionLlm = cfg.visionEnabled
? LlmClient.forPlatform({
baseURL: cfg.visionBaseUrl || cfg.llmBaseUrl,
apiKey: cfg.visionApiKey || cfg.llmApiKey || "missing",
model: cfg.visionModel || cfg.llmModel,
maxTokens: cfg.visionCaptionMaxTokens,
})
: null;
if (cfg.visionEnabled) {
if (!cfg.visionModel) {
console.warn(
"[warn] VISION_ENABLED=true but VISION_MODEL is empty — images will be reported as unreadable. Set VISION_MODEL to a vision-capable model id.",
);
} else {
console.log(
`[config] vision mode=${cfg.visionMode} model=${cfg.visionModel} base=${
cfg.visionBaseUrl || cfg.llmBaseUrl
}`,
);
}
}
const publicBase = cfg.publicBaseUrl.replace(/\/$/, "");
const chat = new ChatService(
db,
llm,
{
shortHistoryLimit: cfg.shortHistoryLimit,
memoryExtractEveryN: cfg.memoryExtractEveryN,
allowUnapproved: cfg.allowUnapproved,
unapprovedReply:
`账号尚未开通对话权限。请前往网页端批准对话权限!\n(此项目为公益免费项目!使用文档:${publicBase}/docs`,
multiBubbleJson: cfg.multiBubbleJson,
replyFilterEnabled: cfg.replyFilterEnabled,
maxReplyBubbles: cfg.maxReplyChunks,
maxChunkChars: cfg.maxChunkChars,
maxStickersPerReply: cfg.maxStickersPerReply,
stickersEnabled: cfg.stickerSendEnabled,
memoryTopK: cfg.memoryTopK,
memoryFullInjectMax: cfg.memoryFullInjectMax,
memoryMaxItems: cfg.memoryMaxItems,
timeToolEnabled: cfg.timeToolEnabled,
timeToolTimeZone: cfg.timeToolTimeZone,
webSearchEnabled: cfg.webSearchEnabled,
toolsBaseUrl: cfg.toolsBaseUrl || undefined,
toolsApiKey: cfg.toolsApiKey || undefined,
llmProviderSecret: cfg.llmProviderSecret || undefined,
chatflowHttpAllowHosts: cfg.chatflowHttpAllowlist,
chatflowMaxSteps: cfg.chatflowMaxSteps,
chatflowMaxNodes: cfg.chatflowMaxNodes,
visionMode: cfg.visionMode,
visionModel: cfg.visionModel || undefined,
visionCaptionMaxTokens: cfg.visionCaptionMaxTokens,
},
visionLlm,
);
const tryChat = new TryChatService(db, llm, {
sessionTtlSec: cfg.tryChatSessionTtlSec,
maxHistory: cfg.tryChatMaxHistory,
maxUserMsgsPerDay: cfg.tryChatMaxUserMsgsPerDay,
maxUserMsgsPerSession: cfg.tryChatMaxUserMsgsPerSession,
multiBubbleJson: cfg.multiBubbleJson,
replyFilterEnabled: cfg.replyFilterEnabled,
maxReplyBubbles: cfg.maxReplyChunks,
maxChunkChars: cfg.maxChunkChars,
timeToolEnabled: cfg.timeToolEnabled,
timeToolTimeZone: cfg.timeToolTimeZone,
toolsBaseUrl: cfg.toolsBaseUrl || undefined,
toolsApiKey: cfg.toolsApiKey || undefined,
webSearchEnabled: cfg.webSearchEnabled,
chatflowHttpAllowHosts: cfg.chatflowHttpAllowlist,
chatflowMaxSteps: cfg.chatflowMaxSteps,
chatflowMaxNodes: cfg.chatflowMaxNodes,
});
const worker = new BotWorkerManager({
db,
chat,
stickerSendEnabled: cfg.stickerSendEnabled,
maxStickersPerReply: cfg.maxStickersPerReply,
visionEnabled: cfg.visionEnabled,
visionMaxImages: cfg.visionMaxImages,
inboundMediaMaxBytes: cfg.inboundMediaMaxBytes,
voiceTranscriptEnabled: cfg.voiceTranscriptEnabled,
peerRatePerMinute: cfg.peerRatePerMinute,
maxBotsPerWorker: cfg.maxBotsPerWorker,
leaseTtlSec: cfg.leaseTtlSec,
leaseRenewSec: cfg.leaseRenewSec,
rebalanceEnabled: cfg.rebalanceEnabled,
rebalanceIntervalSec: cfg.rebalanceIntervalSec,
rebalanceSlack: cfg.rebalanceSlack,
rebalanceMaxPerTick: cfg.rebalanceMaxPerTick,
workerWeightTtlSec: cfg.workerWeightTtlSec,
replyConcurrency: cfg.replyConcurrency,
inboxMaxLen: cfg.inboxMaxLen,
splitReply: cfg.splitReply,
maxReplyChunks: cfg.maxReplyChunks,
maxChunkChars: cfg.maxChunkChars,
replyDelay: {
msPerChar: cfg.replyDelayMsPerChar,
minMs: cfg.replyDelayMinMs,
maxMs: cfg.replyDelayMaxMs,
firstMinMs: cfg.replyDelayFirstMinMs,
firstMaxMs: cfg.replyDelayFirstMaxMs,
thinkExtraMs: cfg.replyDelayThinkExtraMs,
},
proactive: {
globalEnabled: cfg.proactiveEnabled,
defaultIdleHours: cfg.proactiveIdleHours,
defaultMinIntervalHours: cfg.proactiveMinIntervalHours,
defaultMaxPerDay: cfg.proactiveMaxPerDay,
defaultQuietHours: cfg.proactiveQuietHours,
scanIntervalSec: cfg.proactiveScanIntervalSec,
maxPerScan: cfg.proactiveMaxPerScan,
lockTtlSec: cfg.proactiveLockTtlSec,
attemptCooldownHours: cfg.proactiveAttemptCooldownHours,
},
broadcast: {
intervalMs: cfg.broadcastIntervalMs,
pollIntervalMs: 2_000,
lockTtlSec: 60,
},
p2pEnabled: cfg.p2pEnabled,
p2p: {
bindCodeTtlSec: cfg.p2pBindCodeTtlSec,
requestTtlSec: cfg.p2pRequestTtlSec,
sessionIdleSec: cfg.p2pSessionIdleSec,
relayMaxChars: cfg.p2pRelayMaxChars,
maxRequestsPerDay: cfg.p2pMaxRequestsPerDay,
},
nodeLabel: cfg.nodeLabel,
nodeRegion: cfg.nodeRegion,
appVersion: cfg.appVersion,
repoRoot: cfg.repoRoot,
otaEnabled: cfg.otaEnabled,
otaAllowInstall: cfg.otaAllowInstall,
otaStagingDir: cfg.otaStagingDir,
log: (msg, extra) => {
if (extra) console.log(msg, extra);
else console.log(msg);
},
});
runtimeTargets = { chat, tryChat, worker, activityBus };
settings.start();
const loginSessions = new BotLoginSessionManager(db, worker);
// Stickers / OTA blob upload as JSON base64 (~4/3 raw); allow up to ~12MB payload
// 12MB is only needed by the upload routes; as a global default it let any
// unauthenticated POST make the process buffer 12MB before a handler ran.
// Those routes set `bodyLimit: cfg.uploadBodyLimit` per route instead.
const app = Fastify(buildFastifyOptions(cfg));
const rawLogLevel = (process.env.LOG_LEVEL ?? "").trim();
if (rawLogLevel && rawLogLevel.toLowerCase() !== cfg.logLevel) {
app.log.warn(
{ requested: rawLogLevel, using: cfg.logLevel, valid: LOG_LEVELS },
"LOG_LEVEL is not a pino level — falling back",
);
}
registerRequestLogging(app, cfg);
await app.register(compress, {
global: true,
threshold: 4096,
encodings: ["br", "gzip", "deflate"],
// Dynamic JSON gets compressed synchronously on the event loop. Default
// brotli quality is far too slow for 30-60KB admin listings; q4 lands
// near gzip speed at better ratio. Static shells bypass this middleware
// entirely (static-pages.ts sets Content-Encoding itself).
brotliOptions: {
params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 4 },
},
});
await registerRoutes(app, {
db,
chat,
tryChat,
worker,
loginSessions,
cfg,
activityBus,
settings,
});
const publicDir = path.join(__dirname, "../public");
const staticAssets = loadStaticAssets(publicDir, publicBase);
console.log(
`[static] pages=${[...staticAssets.pages.keys()].join(",") || "(none)"} og=${staticAssets.og ? "yes" : "no"}`,
);
const sendCachedPage = (
route: string,
browserCc: string,
edgeCc: string,
req: import("fastify").FastifyRequest,
reply: import("fastify").FastifyReply,
) => {
const page = staticAssets.pages.get(route);
if (!page) return null;
// Serve the boot-time brotli/gzip buffer when the client accepts it.
// Setting Content-Encoding also tells @fastify/compress to stand down.
const variant = pickEncoded(page, req.headers["accept-encoding"]);
const etag = variant?.etag ?? page.etag;
setPublicCache(reply, browserCc, edgeCc, {
etag,
cacheTag: "html-shell",
});
reply.header("Vary", "Accept-Encoding");
if (ifNoneMatchHits(req.headers["if-none-match"], etag)) {
return reply.code(304).send();
}
reply.type(page.contentType);
if (variant) {
reply.header("Content-Encoding", variant.encoding);
return reply.send(variant.body);
}
return reply.send(page.body);
};
// Landing (feature intro + OG for link previews). App console stays at /app.
app.get("/", async (req, reply) => {
const sent = sendCachedPage(
"/",
CC_HTML_MARKETING,
CDN_HTML_MARKETING,
req,
reply,
);
if (sent) return sent;
return reply.redirect("/app");
});
app.get("/app", async (req, reply) => {
const sent = sendCachedPage(
"/app",
CC_HTML_APP,
CDN_HTML_APP,
req,
reply,
);
if (sent) return sent;
return reply.code(404).send("app.html missing");
});
app.get("/docs", async (req, reply) => {
const sent = sendCachedPage(
"/docs",
CC_HTML_MARKETING,
CDN_HTML_MARKETING,
req,
reply,
);
if (sent) return sent;
return reply.code(404).send("docs.html missing");
});
app.get("/admin", async (req, reply) => {
const sent = sendCachedPage(
"/admin",
CC_HTML_APP,
CDN_HTML_APP,
req,
reply,
);
if (sent) return sent;
return reply.code(404).send("admin.html missing");
});
app.get("/chatflow", async (req, reply) => {
const sent = sendCachedPage(
"/chatflow",
CC_HTML_APP,
CDN_HTML_APP,
req,
reply,
);
if (sent) return sent;
return reply.code(404).send("chatflow.html missing");
});
app.get("/og.jpg", async (req, reply) => {
const og = staticAssets.og;
if (!og) return reply.code(404).send("og image missing");
setPublicCache(reply, CC_OG, CDN_OG, {
etag: og.etag,
cacheTag: "og-image",
});
if (ifNoneMatchHits(req.headers["if-none-match"], og.etag)) {
return reply.code(304).send();
}
return reply.type(og.contentType).send(og.body);
});
await app.listen({ host: cfg.host, port: cfg.port });
// Max-quality shell compression, off the boot path
void upgradeStaticCompression(staticAssets).then(
() => console.log("[static] shells recompressed (brotli q11)"),
(err) => console.warn("[static] recompress failed (serving q5):", err),
);
const oauth = loadLinuxDoConfig();
console.log(`Landing http://${cfg.host}:${cfg.port}/`);
console.log(`App UI http://${cfg.host}:${cfg.port}/app`);
console.log(`Docs http://${cfg.host}:${cfg.port}/docs`);
console.log(`Admin UI http://${cfg.host}:${cfg.port}/admin`);
console.log(`Chatflow http://${cfg.host}:${cfg.port}/chatflow`);
console.log(
`[version] ${cfg.appVersion} ota=${cfg.otaEnabled ? "on" : "off"}`,
);
console.log(
oauth
? `[oauth] LINUX DO enabled → ${oauth.redirectUri}`
: "[oauth] LINUX DO 未配置(设置 LINUXDO_CLIENT_ID/SECRET/REDIRECT_URI",
);
if (cfg.workerEnabled) {
// Do not block process forever if Redis is slow; start() is still awaited
// but bootstrap is now batched. Log clearly on failure.
try {
await worker.start();
} catch (err) {
console.error(
"[worker] start failed (API stays up; check Redis / logs):",
err,
);
}
} else {
console.log("WORKER_ENABLED=false");
}
let shuttingDown = false;
const shutdown = async () => {
if (shuttingDown) return;
shuttingDown = true;
settings.stop();
// Await the fleet deregistration so peers re-claim this node's bots
// immediately instead of waiting out the lease TTL.
await worker.stopAsync().catch(() => undefined);
await app.close().catch(() => undefined);
await db.close().catch(() => undefined);
process.exit(0);
};
process.on("SIGINT", () => void shutdown());
process.on("SIGTERM", () => void shutdown());
}
main().catch((err) => {
console.error(err);
process.exit(1);
});