chore: sync local changes to Gitea
This commit is contained in:
68
worker/crypto.ts
Normal file
68
worker/crypto.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/** AES-GCM encrypt/decrypt for API keys at rest. */
|
||||
|
||||
function encEncoder(): TextEncoder {
|
||||
return new TextEncoder();
|
||||
}
|
||||
|
||||
async function getCryptoKey(env: Env): Promise<CryptoKey> {
|
||||
const raw = await resolveRawKey(env);
|
||||
return crypto.subtle.importKey("raw", raw, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]);
|
||||
}
|
||||
|
||||
async function resolveRawKey(env: Env): Promise<ArrayBuffer> {
|
||||
if (env.ENCRYPTION_KEY?.trim()) {
|
||||
const s = env.ENCRYPTION_KEY.trim();
|
||||
try {
|
||||
const buf = Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
|
||||
if (buf.byteLength === 32) return buf.buffer;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
const digest = await crypto.subtle.digest("SHA-256", encEncoder().encode(s));
|
||||
return digest;
|
||||
}
|
||||
const digest = await crypto.subtle.digest("SHA-256", encEncoder().encode(env.ADMIN_TOKEN));
|
||||
return digest;
|
||||
}
|
||||
|
||||
export async function encryptSecret(plain: string, env: Env): Promise<{ ciphertext: ArrayBuffer; nonce: Uint8Array }> {
|
||||
const key = await getCryptoKey(env);
|
||||
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ciphertext = await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, key, encEncoder().encode(plain));
|
||||
return { ciphertext, nonce };
|
||||
}
|
||||
|
||||
/** Normalize D1 BLOB columns: cloud/local may return ArrayBuffer, Uint8Array, or number[]. */
|
||||
export function d1BlobToUint8Array(v: unknown): Uint8Array {
|
||||
if (v == null) throw new TypeError("blob is null or undefined");
|
||||
if (v instanceof Uint8Array) return v;
|
||||
if (v instanceof ArrayBuffer) return new Uint8Array(v);
|
||||
if (Array.isArray(v)) return new Uint8Array(v as number[]);
|
||||
if (typeof v === "object" && "buffer" in v && (v as ArrayBufferView).buffer instanceof ArrayBuffer) {
|
||||
const t = v as ArrayBufferView;
|
||||
return new Uint8Array(t.buffer.slice(t.byteOffset, t.byteOffset + t.byteLength));
|
||||
}
|
||||
throw new TypeError("unsupported BLOB shape from D1");
|
||||
}
|
||||
|
||||
export async function decryptSecret(ciphertext: unknown, nonce: unknown, env: Env): Promise<string> {
|
||||
const ct = d1BlobToUint8Array(ciphertext);
|
||||
const iv = d1BlobToUint8Array(nonce);
|
||||
const key = await getCryptoKey(env);
|
||||
const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv: iv }, key, ct);
|
||||
return new TextDecoder().decode(plain);
|
||||
}
|
||||
|
||||
export function toB64(buf: ArrayBuffer): string {
|
||||
const u8 = new Uint8Array(buf);
|
||||
let s = "";
|
||||
for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]!);
|
||||
return btoa(s);
|
||||
}
|
||||
|
||||
export function fromB64(s: string): ArrayBuffer {
|
||||
const bin = atob(s);
|
||||
const u8 = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) u8[i] = bin.charCodeAt(i);
|
||||
return u8.buffer;
|
||||
}
|
||||
370
worker/db.ts
Normal file
370
worker/db.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
import { d1BlobToUint8Array } from "./crypto";
|
||||
import type { Protocol } from "./probe";
|
||||
|
||||
const THIRTY_DAYS_SEC = 30 * 24 * 60 * 60;
|
||||
|
||||
export async function pruneProbeEvents(db: D1Database): Promise<void> {
|
||||
const cutoff = Math.floor(Date.now() / 1000) - THIRTY_DAYS_SEC;
|
||||
await db.prepare("DELETE FROM probe_events WHERE ts < ?").bind(cutoff).run();
|
||||
}
|
||||
|
||||
export type AppSettingsRow = {
|
||||
id: number;
|
||||
probe_prompts: string;
|
||||
probe_interval_minutes: number;
|
||||
};
|
||||
|
||||
export async function getAppSettings(db: D1Database): Promise<AppSettingsRow> {
|
||||
const r = await db
|
||||
.prepare("SELECT id, probe_prompts, probe_interval_minutes FROM app_settings WHERE id = 1")
|
||||
.first<AppSettingsRow>();
|
||||
if (r) return r;
|
||||
await db
|
||||
.prepare("INSERT INTO app_settings (id, probe_prompts, probe_interval_minutes) VALUES (1, '', 5)")
|
||||
.run();
|
||||
return { id: 1, probe_prompts: "", probe_interval_minutes: 5 };
|
||||
}
|
||||
|
||||
export async function updateAppSettings(
|
||||
db: D1Database,
|
||||
patch: Partial<Pick<AppSettingsRow, "probe_prompts" | "probe_interval_minutes">>
|
||||
): Promise<void> {
|
||||
const cur = await getAppSettings(db);
|
||||
const next = {
|
||||
probe_prompts: patch.probe_prompts !== undefined ? patch.probe_prompts : cur.probe_prompts,
|
||||
probe_interval_minutes:
|
||||
patch.probe_interval_minutes !== undefined ? patch.probe_interval_minutes : cur.probe_interval_minutes,
|
||||
};
|
||||
await db
|
||||
.prepare("UPDATE app_settings SET probe_prompts = ?, probe_interval_minutes = ? WHERE id = 1")
|
||||
.bind(next.probe_prompts, next.probe_interval_minutes)
|
||||
.run();
|
||||
}
|
||||
|
||||
/** 库中存证的监控行(不含全站探测间隔,间隔在 app_settings) */
|
||||
export type MonitorRow = {
|
||||
id: string;
|
||||
display_name: string;
|
||||
api_base_url: string;
|
||||
model: string;
|
||||
protocol: Protocol;
|
||||
enabled: number;
|
||||
category: string;
|
||||
created_at: number;
|
||||
api_key_ciphertext: ArrayBuffer;
|
||||
api_key_nonce: ArrayBuffer;
|
||||
last_run_at: number | null;
|
||||
next_run_at: number;
|
||||
/** 1 = 流式;0 = 非流式 */
|
||||
probe_stream: number;
|
||||
/** 1 = 首页卡片展示;0 = 仅后台可见 */
|
||||
show_on_dashboard: number;
|
||||
};
|
||||
|
||||
/** 列表 API 在合并 interval_minutes 之前的行 */
|
||||
export type MonitorPublic = {
|
||||
id: string;
|
||||
display_name: string;
|
||||
api_base_url: string;
|
||||
model: string;
|
||||
protocol: Protocol;
|
||||
enabled: number;
|
||||
category: string;
|
||||
created_at: number;
|
||||
last_run_at: number | null;
|
||||
next_run_at: number;
|
||||
probe_stream: number;
|
||||
show_on_dashboard: number;
|
||||
};
|
||||
|
||||
export async function listMonitorsPublic(
|
||||
db: D1Database,
|
||||
visibility: "all" | "dashboard" = "all"
|
||||
): Promise<MonitorPublic[]> {
|
||||
const where = visibility === "dashboard" ? " WHERE show_on_dashboard = 1" : "";
|
||||
const r = await db
|
||||
.prepare(
|
||||
`SELECT id, display_name, api_base_url, model, protocol, enabled, category,
|
||||
created_at, last_run_at, next_run_at, probe_stream, show_on_dashboard
|
||||
FROM monitors${where} ORDER BY display_name ASC`
|
||||
)
|
||||
.all();
|
||||
return (r.results ?? []) as unknown as MonitorPublic[];
|
||||
}
|
||||
|
||||
export async function listAllMonitors(db: D1Database): Promise<MonitorRow[]> {
|
||||
const r = await db
|
||||
.prepare(
|
||||
`SELECT id, display_name, api_base_url, model, protocol, enabled, category,
|
||||
created_at, api_key_ciphertext, api_key_nonce, last_run_at, next_run_at, probe_stream,
|
||||
show_on_dashboard
|
||||
FROM monitors ORDER BY display_name ASC`
|
||||
)
|
||||
.all();
|
||||
return (r.results ?? []) as unknown as MonitorRow[];
|
||||
}
|
||||
|
||||
export async function listDueMonitors(db: D1Database, nowSec: number): Promise<MonitorRow[]> {
|
||||
const r = await db
|
||||
.prepare(
|
||||
`SELECT id, display_name, api_base_url, model, protocol, enabled, category,
|
||||
created_at, api_key_ciphertext, api_key_nonce, last_run_at, next_run_at, probe_stream,
|
||||
show_on_dashboard
|
||||
FROM monitors WHERE enabled = 1 AND next_run_at <= ? ORDER BY next_run_at ASC`
|
||||
)
|
||||
.bind(nowSec)
|
||||
.all();
|
||||
return (r.results ?? []) as unknown as MonitorRow[];
|
||||
}
|
||||
|
||||
export async function getMonitor(db: D1Database, id: string): Promise<MonitorRow | null> {
|
||||
const r = await db
|
||||
.prepare(
|
||||
`SELECT id, display_name, api_base_url, model, protocol, enabled, category,
|
||||
created_at, api_key_ciphertext, api_key_nonce, last_run_at, next_run_at, probe_stream,
|
||||
show_on_dashboard
|
||||
FROM monitors WHERE id = ?`
|
||||
)
|
||||
.bind(id)
|
||||
.first();
|
||||
return (r as unknown as MonitorRow) ?? null;
|
||||
}
|
||||
|
||||
export async function insertMonitor(
|
||||
db: D1Database,
|
||||
row: Omit<MonitorRow, "last_run_at"> & { last_run_at: number | null }
|
||||
): Promise<void> {
|
||||
const ct = d1BlobToUint8Array(row.api_key_ciphertext as unknown);
|
||||
const nn = d1BlobToUint8Array(row.api_key_nonce as unknown);
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO monitors (id, display_name, api_base_url, model, protocol, enabled, category,
|
||||
created_at, api_key_ciphertext, api_key_nonce, last_run_at, next_run_at, probe_stream,
|
||||
show_on_dashboard)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.bind(
|
||||
row.id,
|
||||
row.display_name,
|
||||
row.api_base_url,
|
||||
row.model,
|
||||
row.protocol,
|
||||
row.enabled,
|
||||
row.category,
|
||||
row.created_at,
|
||||
ct,
|
||||
nn,
|
||||
row.last_run_at,
|
||||
row.next_run_at,
|
||||
row.probe_stream,
|
||||
row.show_on_dashboard
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function updateMonitorMeta(
|
||||
db: D1Database,
|
||||
id: string,
|
||||
patch: Partial<{
|
||||
display_name: string;
|
||||
api_base_url: string;
|
||||
model: string;
|
||||
protocol: Protocol;
|
||||
category: string;
|
||||
enabled: number;
|
||||
api_key_ciphertext: ArrayBuffer;
|
||||
api_key_nonce: ArrayBuffer;
|
||||
next_run_at: number;
|
||||
probe_stream: number;
|
||||
show_on_dashboard: number;
|
||||
}>
|
||||
): Promise<void> {
|
||||
const cur = await getMonitor(db, id);
|
||||
if (!cur) return;
|
||||
const next = { ...cur, ...patch };
|
||||
const ct = d1BlobToUint8Array(next.api_key_ciphertext as unknown);
|
||||
const nn = d1BlobToUint8Array(next.api_key_nonce as unknown);
|
||||
await db
|
||||
.prepare(
|
||||
`UPDATE monitors SET display_name=?, api_base_url=?, model=?, protocol=?,
|
||||
enabled=?, category=?, api_key_ciphertext=?, api_key_nonce=?, next_run_at=?, probe_stream=?,
|
||||
show_on_dashboard=?
|
||||
WHERE id=?`
|
||||
)
|
||||
.bind(
|
||||
next.display_name,
|
||||
next.api_base_url,
|
||||
next.model,
|
||||
next.protocol,
|
||||
next.enabled,
|
||||
next.category,
|
||||
ct,
|
||||
nn,
|
||||
next.next_run_at,
|
||||
next.probe_stream,
|
||||
next.show_on_dashboard,
|
||||
id
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function deleteMonitor(db: D1Database, id: string): Promise<void> {
|
||||
await db.prepare("DELETE FROM probe_events WHERE monitor_id = ?").bind(id).run();
|
||||
await db.prepare("DELETE FROM monitors WHERE id = ?").bind(id).run();
|
||||
}
|
||||
|
||||
export async function insertProbeEvent(
|
||||
db: D1Database,
|
||||
e: {
|
||||
monitor_id: string;
|
||||
ts: number;
|
||||
ok: number;
|
||||
first_token_ms: number | null;
|
||||
http_status: number | null;
|
||||
error_message: string | null;
|
||||
probe_input: string | null;
|
||||
probe_output: string | null;
|
||||
}
|
||||
): Promise<void> {
|
||||
await db
|
||||
.prepare(
|
||||
`INSERT INTO probe_events (monitor_id, ts, ok, first_token_ms, http_status, error_message, probe_input, probe_output)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.bind(
|
||||
e.monitor_id,
|
||||
e.ts,
|
||||
e.ok,
|
||||
e.first_token_ms,
|
||||
e.http_status,
|
||||
e.error_message,
|
||||
e.probe_input,
|
||||
e.probe_output
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function updateMonitorRunTimes(
|
||||
db: D1Database,
|
||||
id: string,
|
||||
last_run_at: number,
|
||||
next_run_at: number
|
||||
): Promise<void> {
|
||||
await db
|
||||
.prepare("UPDATE monitors SET last_run_at = ?, next_run_at = ? WHERE id = ?")
|
||||
.bind(last_run_at, next_run_at, id)
|
||||
.run();
|
||||
}
|
||||
|
||||
export type AvailabilityTimelineStats = {
|
||||
availability24h: number | null;
|
||||
availability30d: number | null;
|
||||
probe_count: number;
|
||||
daily: Array<{ dayStart: number; ok: number; total: number }>;
|
||||
lastProbe: {
|
||||
ts: number;
|
||||
ok: number;
|
||||
first_token_ms: number | null;
|
||||
http_status: number | null;
|
||||
error_message: string | null;
|
||||
probe_input: string | null;
|
||||
probe_output: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
type LastProbeRow = NonNullable<AvailabilityTimelineStats["lastProbe"]>;
|
||||
|
||||
function finalizeAvailabilityTimeline(
|
||||
lastProbe: LastProbeRow | null | undefined,
|
||||
stats24: { okc: number | null; total: number | null } | null | undefined,
|
||||
stats30: { okc: number | null; total: number | null } | null | undefined,
|
||||
dailyRaw: unknown[],
|
||||
countRow: { c: number | null } | null | undefined
|
||||
): AvailabilityTimelineStats {
|
||||
const daily = dailyRaw.map((r) => ({
|
||||
dayStart: Number((r as { day_start: number }).day_start),
|
||||
ok: Number((r as { okc: number | null }).okc ?? 0),
|
||||
total: Number((r as { cnt: number | null }).cnt ?? 0),
|
||||
}));
|
||||
|
||||
const availability24h =
|
||||
stats24?.total && stats24.total > 0 ? (Number(stats24.okc ?? 0) / Number(stats24.total)) * 100 : null;
|
||||
const availability30d =
|
||||
stats30?.total && stats30.total > 0 ? (Number(stats30.okc ?? 0) / Number(stats30.total)) * 100 : null;
|
||||
|
||||
return {
|
||||
availability24h,
|
||||
availability30d,
|
||||
probe_count: Number(countRow?.c ?? 0),
|
||||
daily,
|
||||
lastProbe: lastProbe ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 与 SQL bind / Map 查找统一,避免首尾空白或类型不一致导致 stats 对不上 */
|
||||
export function normMonitorId(id: unknown): string {
|
||||
return String(id ?? "").trim();
|
||||
}
|
||||
|
||||
/** 每个监控单独 batch(5 条语句),多监控并发;避免单次 mega-batch 下结果与语句顺序偶发错位 */
|
||||
const AV_STATS_PARALLEL_MONITORS = 12;
|
||||
|
||||
/**
|
||||
* 批量拉取多个监控的可用率与时间线(每位监控 1 次 `db.batch`,含 5 条 SELECT)。
|
||||
*/
|
||||
export async function availabilityAndTimelineBatch(
|
||||
db: D1Database,
|
||||
monitorIds: string[]
|
||||
): Promise<Map<string, AvailabilityTimelineStats>> {
|
||||
const out = new Map<string, AvailabilityTimelineStats>();
|
||||
const unique = [...new Set(monitorIds.map(normMonitorId).filter((id) => id.length > 0))];
|
||||
if (unique.length === 0) return out;
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const start24 = now - 86400;
|
||||
const start30 = now - THIRTY_DAYS_SEC;
|
||||
|
||||
const sqlLast = `SELECT ts, ok, first_token_ms, http_status, error_message, probe_input, probe_output FROM probe_events WHERE monitor_id = ? ORDER BY ts DESC LIMIT 1`;
|
||||
const sqlRange = `SELECT SUM(ok) AS okc, COUNT(*) AS total FROM probe_events WHERE monitor_id = ? AND ts >= ?`;
|
||||
const sqlDaily = `SELECT (CAST((ts + 28800) / 43200 AS INTEGER) * 43200 - 28800) AS day_start,
|
||||
SUM(ok) AS okc,
|
||||
COUNT(*) AS cnt
|
||||
FROM probe_events WHERE monitor_id = ? AND ts >= ?
|
||||
GROUP BY day_start ORDER BY day_start ASC`;
|
||||
const sqlCount = `SELECT COUNT(*) AS c FROM probe_events WHERE monitor_id = ?`;
|
||||
|
||||
async function fetchOne(monitorId: string): Promise<void> {
|
||||
const batchResults = await db.batch([
|
||||
db.prepare(sqlLast).bind(monitorId),
|
||||
db.prepare(sqlRange).bind(monitorId, start24),
|
||||
db.prepare(sqlRange).bind(monitorId, start30),
|
||||
db.prepare(sqlDaily).bind(monitorId, start30),
|
||||
db.prepare(sqlCount).bind(monitorId),
|
||||
]);
|
||||
|
||||
const lastProbe = batchResults[0].results?.[0] as LastProbeRow | undefined;
|
||||
const stats24 = batchResults[1].results?.[0] as { okc: number | null; total: number | null } | undefined;
|
||||
const stats30 = batchResults[2].results?.[0] as { okc: number | null; total: number | null } | undefined;
|
||||
const dailyRaw = batchResults[3].results ?? [];
|
||||
const countRow = batchResults[4].results?.[0] as { c: number | null } | undefined;
|
||||
|
||||
out.set(monitorId, finalizeAvailabilityTimeline(lastProbe, stats24, stats30, dailyRaw, countRow));
|
||||
}
|
||||
|
||||
for (let off = 0; off < unique.length; off += AV_STATS_PARALLEL_MONITORS) {
|
||||
const slice = unique.slice(off, off + AV_STATS_PARALLEL_MONITORS);
|
||||
await Promise.all(slice.map((id) => fetchOne(id)));
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Timeline bucket: half-day slots aligned to Asia/Shanghai 00:00 and 12:00. */
|
||||
export async function availabilityAndTimeline(
|
||||
db: D1Database,
|
||||
monitorId: string
|
||||
): Promise<AvailabilityTimelineStats> {
|
||||
const id = normMonitorId(monitorId);
|
||||
const m = await availabilityAndTimelineBatch(db, [id]);
|
||||
return m.get(id) ?? finalizeAvailabilityTimeline(null, null, null, [], null);
|
||||
}
|
||||
340
worker/index.ts
Normal file
340
worker/index.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
import { Hono } from "hono";
|
||||
import {
|
||||
availabilityAndTimeline,
|
||||
availabilityAndTimelineBatch,
|
||||
getAppSettings,
|
||||
getMonitor,
|
||||
listMonitorsPublic,
|
||||
normMonitorId,
|
||||
updateAppSettings,
|
||||
} from "./db";
|
||||
import {
|
||||
createMonitorFromPayload,
|
||||
deleteMonitor,
|
||||
runScheduled,
|
||||
runSingleProbe,
|
||||
updateMonitorFromPayload,
|
||||
} from "./scheduler";
|
||||
|
||||
type MonitorCreateBody = {
|
||||
display_name: string;
|
||||
api_base_url: string;
|
||||
api_key: string;
|
||||
model: string;
|
||||
protocol: "openai" | "openai_responses" | "claude";
|
||||
category?: string;
|
||||
enabled?: boolean;
|
||||
/** 未传时默认为流式 */
|
||||
probe_stream?: boolean;
|
||||
/** 未传时默认在首页展示 */
|
||||
show_on_dashboard?: boolean;
|
||||
};
|
||||
|
||||
function parseOptionalBool01(o: Record<string, unknown>, key: string): boolean | undefined {
|
||||
const v = o[key];
|
||||
if (typeof v === "boolean") return v;
|
||||
if (v === 0) return false;
|
||||
if (v === 1) return true;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseOptionalProbeStream(o: Record<string, unknown>): boolean | undefined {
|
||||
return parseOptionalBool01(o, "probe_stream");
|
||||
}
|
||||
|
||||
const app = new Hono<{ Bindings: Env }>();
|
||||
|
||||
/** 避免边缘/浏览器把动态 JSON 缓存成「半套字段」,尤其带 Authorization 的请求易被错误复用 */
|
||||
app.use("/api/*", async (c, next) => {
|
||||
c.header("Cache-Control", "private, no-store, max-age=0");
|
||||
await next();
|
||||
});
|
||||
|
||||
const ALLOWED = new Set([1, 5, 10, 30, 60, 360]);
|
||||
|
||||
function checkAdmin(c: { req: { header: (k: string) => string | undefined }; env: Env }): Response | null {
|
||||
const configured = (c.env.ADMIN_TOKEN ?? "").trim();
|
||||
if (!configured) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: "admin_token_not_configured",
|
||||
message: "Configure ADMIN_TOKEN in .dev.vars (local) or wrangler secret put ADMIN_TOKEN (production).",
|
||||
}),
|
||||
{ status: 503, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
const h = c.req.header("Authorization") ?? "";
|
||||
const m = /^Bearer\s+(.+)$/i.exec(h);
|
||||
const bearer = (m?.[1] ?? "").trim();
|
||||
if (bearer !== configured) {
|
||||
return new Response(JSON.stringify({ error: "unauthorized" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function parseMonitorCreate(c: { req: { json: () => Promise<unknown> } }): Promise<MonitorCreateBody | null> {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!body || typeof body !== "object") return null;
|
||||
const o = body as Record<string, unknown>;
|
||||
const display_name = typeof o.display_name === "string" ? o.display_name : "";
|
||||
const api_base_url = typeof o.api_base_url === "string" ? o.api_base_url : "";
|
||||
const api_key = typeof o.api_key === "string" ? o.api_key : "";
|
||||
const model = typeof o.model === "string" ? o.model : "";
|
||||
const protocol =
|
||||
o.protocol === "openai" || o.protocol === "openai_responses" || o.protocol === "claude" ? o.protocol : null;
|
||||
if (!display_name.trim() || !api_base_url.trim() || !api_key.trim() || !model.trim() || !protocol) {
|
||||
return null;
|
||||
}
|
||||
const category = typeof o.category === "string" ? o.category : undefined;
|
||||
const enabled = typeof o.enabled === "boolean" ? o.enabled : undefined;
|
||||
const probe_stream = parseOptionalProbeStream(o);
|
||||
const show_on_dashboard = parseOptionalBool01(o, "show_on_dashboard");
|
||||
return {
|
||||
display_name,
|
||||
api_base_url,
|
||||
api_key,
|
||||
model,
|
||||
protocol,
|
||||
category,
|
||||
enabled,
|
||||
probe_stream,
|
||||
show_on_dashboard,
|
||||
};
|
||||
}
|
||||
|
||||
type MonitorRowWithInterval = Awaited<ReturnType<typeof listMonitorsPublic>>[number] & {
|
||||
interval_minutes: number;
|
||||
};
|
||||
|
||||
const TIMELINE_SHANGHAI_OFF = 8 * 3600;
|
||||
const TIMELINE_HALF_DAY_SEC = 12 * 60 * 60;
|
||||
const TIMELINE_SLOTS = 60;
|
||||
|
||||
function currentTimelineSlotStart(nowSec: number): number {
|
||||
return Math.floor((nowSec + TIMELINE_SHANGHAI_OFF) / TIMELINE_HALF_DAY_SEC) * TIMELINE_HALF_DAY_SEC - TIMELINE_SHANGHAI_OFF;
|
||||
}
|
||||
|
||||
function timelineDailyFromBuckets(daily: Array<{ dayStart: number; ok: number; total: number }>) {
|
||||
const bySlot = new Map(daily.map((d) => [d.dayStart, d]));
|
||||
const current = currentTimelineSlotStart(Math.floor(Date.now() / 1000));
|
||||
const first = current - (TIMELINE_SLOTS - 1) * TIMELINE_HALF_DAY_SEC;
|
||||
|
||||
return Array.from({ length: TIMELINE_SLOTS }, (_, i) => {
|
||||
const t = first + i * TIMELINE_HALF_DAY_SEC;
|
||||
const d = bySlot.get(t);
|
||||
const total = d?.total ?? 0;
|
||||
const ratio = total > 0 ? (d?.ok ?? 0) / total : 0;
|
||||
return {
|
||||
t,
|
||||
up: total > 0 && (d?.ok ?? 0) === total,
|
||||
ratio,
|
||||
hasData: total > 0,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function monitorsDtoList(db: D1Database, merged: MonitorRowWithInterval[]) {
|
||||
const statsMap = await availabilityAndTimelineBatch(
|
||||
db,
|
||||
merged.map((m) => m.id)
|
||||
);
|
||||
return merged.map((m) => {
|
||||
const stats = statsMap.get(normMonitorId(m.id));
|
||||
if (!stats) {
|
||||
return {
|
||||
...m,
|
||||
availability24h: null,
|
||||
availability30d: null,
|
||||
probe_count: 0,
|
||||
lastProbe: null,
|
||||
timelineDaily: [],
|
||||
};
|
||||
}
|
||||
const timelineDaily = timelineDailyFromBuckets(stats.daily);
|
||||
return {
|
||||
...m,
|
||||
availability24h: stats.availability24h,
|
||||
availability30d: stats.availability30d,
|
||||
probe_count: stats.probe_count,
|
||||
lastProbe: stats.lastProbe,
|
||||
timelineDaily,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
app.get("/api/monitors", async (c) => {
|
||||
const db = c.env.DB;
|
||||
const [rows, s] = await Promise.all([listMonitorsPublic(db, "dashboard"), getAppSettings(db)]);
|
||||
const merged: MonitorRowWithInterval[] = rows.map((m) => ({
|
||||
...m,
|
||||
interval_minutes: s.probe_interval_minutes,
|
||||
}));
|
||||
return c.json(await monitorsDtoList(db, merged));
|
||||
});
|
||||
|
||||
app.get("/api/monitors/:id", async (c) => {
|
||||
const id = c.req.param("id");
|
||||
const db = c.env.DB;
|
||||
const [m, s] = await Promise.all([getMonitor(db, id), getAppSettings(db)]);
|
||||
if (!m) return c.json({ error: "not_found" }, 404);
|
||||
if (m.show_on_dashboard === 0) return c.json({ error: "not_found" }, 404);
|
||||
const stats = await availabilityAndTimeline(db, id);
|
||||
const timelineDaily = timelineDailyFromBuckets(stats.daily);
|
||||
return c.json({
|
||||
id: m.id,
|
||||
display_name: m.display_name,
|
||||
api_base_url: m.api_base_url,
|
||||
model: m.model,
|
||||
protocol: m.protocol,
|
||||
interval_minutes: s.probe_interval_minutes,
|
||||
probe_stream: m.probe_stream,
|
||||
show_on_dashboard: m.show_on_dashboard,
|
||||
enabled: m.enabled,
|
||||
category: m.category,
|
||||
created_at: m.created_at,
|
||||
last_run_at: m.last_run_at,
|
||||
next_run_at: m.next_run_at,
|
||||
availability24h: stats.availability24h,
|
||||
availability30d: stats.availability30d,
|
||||
probe_count: stats.probe_count,
|
||||
lastProbe: stats.lastProbe,
|
||||
timelineDaily,
|
||||
});
|
||||
});
|
||||
|
||||
app.get("/api/admin/ping", (c) => {
|
||||
const err = checkAdmin(c);
|
||||
if (err) return err;
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get("/api/admin/probe-settings", async (c) => {
|
||||
const err = checkAdmin(c);
|
||||
if (err) return err;
|
||||
const s = await getAppSettings(c.env.DB);
|
||||
return c.json({
|
||||
probe_prompts: s.probe_prompts,
|
||||
probe_interval_minutes: s.probe_interval_minutes,
|
||||
});
|
||||
});
|
||||
|
||||
app.put("/api/admin/probe-settings", async (c) => {
|
||||
const err = checkAdmin(c);
|
||||
if (err) return err;
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = (await c.req.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return c.json({ error: "invalid_json" }, 400);
|
||||
}
|
||||
const patch: Partial<{ probe_prompts: string; probe_interval_minutes: number }> = {};
|
||||
if (typeof body.probe_prompts === "string") patch.probe_prompts = body.probe_prompts;
|
||||
if (typeof body.probe_interval_minutes === "number") {
|
||||
if (!ALLOWED.has(body.probe_interval_minutes)) {
|
||||
return c.json({ error: "invalid_interval" }, 400);
|
||||
}
|
||||
patch.probe_interval_minutes = body.probe_interval_minutes as 1 | 5 | 10 | 30 | 60 | 360;
|
||||
}
|
||||
if (Object.keys(patch).length === 0) return c.json({ error: "invalid_body" }, 400);
|
||||
try {
|
||||
await updateAppSettings(c.env.DB, patch);
|
||||
return c.json({ ok: true });
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
return c.json({ error: "update_failed", message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/api/admin/monitors", async (c) => {
|
||||
const err = checkAdmin(c);
|
||||
if (err) return err;
|
||||
const db = c.env.DB;
|
||||
const [rows, s] = await Promise.all([listMonitorsPublic(db, "all"), getAppSettings(db)]);
|
||||
const merged: MonitorRowWithInterval[] = rows.map((m) => ({
|
||||
...m,
|
||||
interval_minutes: s.probe_interval_minutes,
|
||||
}));
|
||||
return c.json(await monitorsDtoList(db, merged));
|
||||
});
|
||||
|
||||
app.post("/api/admin/monitors", async (c) => {
|
||||
const err = checkAdmin(c);
|
||||
if (err) return err;
|
||||
const parsed = await parseMonitorCreate(c);
|
||||
if (!parsed) return c.json({ error: "invalid_body" }, 400);
|
||||
const { id } = await createMonitorFromPayload(c.env, c.env.DB, parsed);
|
||||
return c.json({ id }, 201);
|
||||
});
|
||||
|
||||
app.put("/api/admin/monitors/:id", async (c) => {
|
||||
const err = checkAdmin(c);
|
||||
if (err) return err;
|
||||
const id = c.req.param("id");
|
||||
let body: Record<string, unknown>;
|
||||
try {
|
||||
body = (await c.req.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
return c.json({ error: "invalid_json" }, 400);
|
||||
}
|
||||
const patch: Parameters<typeof updateMonitorFromPayload>[3] = {};
|
||||
if (typeof body.display_name === "string") patch.display_name = body.display_name;
|
||||
if (typeof body.api_base_url === "string") patch.api_base_url = body.api_base_url;
|
||||
if (typeof body.api_key === "string") patch.api_key = body.api_key;
|
||||
if (typeof body.model === "string") patch.model = body.model;
|
||||
if (body.protocol === "openai" || body.protocol === "openai_responses" || body.protocol === "claude")
|
||||
patch.protocol = body.protocol;
|
||||
if (typeof body.category === "string") patch.category = body.category;
|
||||
if (typeof body.enabled === "boolean") patch.enabled = body.enabled;
|
||||
if (typeof body.probe_stream === "boolean") patch.probe_stream = body.probe_stream;
|
||||
else if (body.probe_stream === 0 || body.probe_stream === 1) patch.probe_stream = body.probe_stream === 1;
|
||||
if (typeof body.show_on_dashboard === "boolean") patch.show_on_dashboard = body.show_on_dashboard;
|
||||
else if (body.show_on_dashboard === 0 || body.show_on_dashboard === 1)
|
||||
patch.show_on_dashboard = body.show_on_dashboard === 1;
|
||||
try {
|
||||
const ok = await updateMonitorFromPayload(c.env, c.env.DB, id, patch);
|
||||
if (!ok) return c.json({ error: "not_found" }, 404);
|
||||
return c.json({ ok: true });
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
return c.json({ error: "update_failed", message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/api/admin/monitors/:id", async (c) => {
|
||||
const err = checkAdmin(c);
|
||||
if (err) return err;
|
||||
const id = c.req.param("id");
|
||||
const m = await getMonitor(c.env.DB, id);
|
||||
if (!m) return c.json({ error: "not_found" }, 404);
|
||||
await deleteMonitor(c.env.DB, id);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post("/api/admin/monitors/:id/run", async (c) => {
|
||||
const err = checkAdmin(c);
|
||||
if (err) return err;
|
||||
const id = c.req.param("id");
|
||||
const m = await getMonitor(c.env.DB, id);
|
||||
if (!m) return c.json({ error: "not_found" }, 404);
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const outcome = await runSingleProbe(c.env, id, c.env.DB, nowSec);
|
||||
if (!outcome.success) {
|
||||
return c.json({ ok: false, error: outcome.error }, 200);
|
||||
}
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
export default {
|
||||
fetch: app.fetch,
|
||||
scheduled: (_event: ScheduledEvent, env: Env, ctx: ExecutionContext) => {
|
||||
ctx.waitUntil(runScheduled(env));
|
||||
},
|
||||
};
|
||||
583
worker/probe.ts
Normal file
583
worker/probe.ts
Normal file
@@ -0,0 +1,583 @@
|
||||
export type Protocol = "openai" | "openai_responses" | "claude";
|
||||
|
||||
const PROBE_TIMEOUT_MS = 45_000;
|
||||
const PROBE_MAX_TOKENS = 256;
|
||||
/** 存入 D1 前在 Worker 侧截断的流式输出上限 */
|
||||
const STREAM_OUTPUT_CAP = 4096;
|
||||
/** 非 2xx 时读取响应体截断长度(scheduler 还会再截断入库) */
|
||||
const HTTP_ERROR_BODY_MAX = 6000;
|
||||
|
||||
function tryFormatJsonBody(raw: string): string {
|
||||
const t = raw.trim();
|
||||
if (!t.startsWith("{") && !t.startsWith("[")) return raw;
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(t), null, 2);
|
||||
} catch {
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
async function readHttpErrorBody(res: Response): Promise<string> {
|
||||
try {
|
||||
const raw = (await res.text()).trim();
|
||||
if (!raw) return "";
|
||||
const formatted = tryFormatJsonBody(raw);
|
||||
if (formatted.length <= HTTP_ERROR_BODY_MAX) return formatted;
|
||||
return formatted.slice(0, HTTP_ERROR_BODY_MAX) + "…";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function buildHttpErrorMessage(res: Response, body: string): string {
|
||||
const line = `HTTP ${res.status}${res.statusText ? ` ${res.statusText}` : ""}`;
|
||||
if (!body) return `${line}\n(响应体为空;常见原因:网关未返回 JSON 错误详情)`;
|
||||
return `${line}\n\n${body}`;
|
||||
}
|
||||
|
||||
function normalizeBase(url: string): string {
|
||||
return url.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function appendCap(base: string, add: string, max: number): string {
|
||||
if (base.length >= max) return base;
|
||||
const room = max - base.length;
|
||||
return base + (add.length <= room ? add : add.slice(0, room));
|
||||
}
|
||||
|
||||
/** 经典 Chat Completions(/v1/chat/completions),兼容绝大多数 OpenAI 兼容网关 */
|
||||
function openAiChatUrl(base: string): string {
|
||||
const b = normalizeBase(base);
|
||||
if (b.endsWith("/chat/completions")) return b;
|
||||
if (b.endsWith("/v1")) return `${b}/chat/completions`;
|
||||
return `${b}/chat/completions`;
|
||||
}
|
||||
|
||||
/** OpenAI Responses API(/v1/responses) */
|
||||
function openAiResponsesUrl(base: string): string {
|
||||
const b = normalizeBase(base);
|
||||
if (b.endsWith("/responses")) return b;
|
||||
if (b.endsWith("/v1")) return `${b}/responses`;
|
||||
return `${b}/v1/responses`;
|
||||
}
|
||||
|
||||
function claudeUrl(base: string): string {
|
||||
const b = normalizeBase(base);
|
||||
if (b.endsWith("/messages")) return b;
|
||||
if (b.endsWith("/v1")) return `${b}/messages`;
|
||||
return `${b}/v1/messages`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Chat Completions 流式里 `choices[0].delta` 的正文抽取。
|
||||
* 对齐 OpenAI 常见形态:delta.content 字符串;多段/多模态下为 part 数组;
|
||||
* 部分国产/推理模型会在 delta.reasoning_content 等字段里先出字。
|
||||
*/
|
||||
function streamDeltaTextFromOpenAiChatChoice(delta: unknown): string {
|
||||
if (delta == null || typeof delta !== "object") return "";
|
||||
const d = delta as Record<string, unknown>;
|
||||
const c = d.content;
|
||||
if (typeof c === "string" && c !== "") return c;
|
||||
if (Array.isArray(c)) {
|
||||
let s = "";
|
||||
for (const part of c) {
|
||||
if (part && typeof part === "object") {
|
||||
const p = part as { type?: string; text?: string };
|
||||
if (typeof p.text === "string") s += p.text;
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
for (const k of ["reasoning_content", "reasoning"] as const) {
|
||||
const v = d[k];
|
||||
if (typeof v === "string" && v !== "") return v;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
async function readStreamOpenAIChat(
|
||||
res: Response,
|
||||
started: number
|
||||
): Promise<{ firstTokenMs: number | null; httpStatus: number; outputText: string }> {
|
||||
if (!res.ok || !res.body) {
|
||||
return { firstTokenMs: null, httpStatus: res.status, outputText: "" };
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let buf = "";
|
||||
let firstTokenMs: number | null = null;
|
||||
let outputText = "";
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += dec.decode(value, { stream: true });
|
||||
const lines = buf.split("\n");
|
||||
buf = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
const t = line.trim();
|
||||
if (!t.startsWith("data:")) continue;
|
||||
const payload = t.slice(5).trim();
|
||||
if (payload === "[DONE]") {
|
||||
return { firstTokenMs, httpStatus: res.status, outputText };
|
||||
}
|
||||
try {
|
||||
const obj = JSON.parse(payload) as {
|
||||
choices?: Array<{ delta?: unknown }>;
|
||||
};
|
||||
const delta = obj.choices?.[0]?.delta;
|
||||
const chunk = streamDeltaTextFromOpenAiChatChoice(delta);
|
||||
if (chunk !== "") {
|
||||
if (firstTokenMs == null) firstTokenMs = Math.max(0, Date.now() - started);
|
||||
outputText = appendCap(outputText, chunk, STREAM_OUTPUT_CAP);
|
||||
}
|
||||
} catch {
|
||||
/* ignore bad json line */
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
return { firstTokenMs, httpStatus: res.status, outputText };
|
||||
}
|
||||
|
||||
async function readStreamOpenAIResponses(
|
||||
res: Response,
|
||||
started: number
|
||||
): Promise<{ firstTokenMs: number | null; httpStatus: number; outputText: string }> {
|
||||
if (!res.ok || !res.body) {
|
||||
return { firstTokenMs: null, httpStatus: res.status, outputText: "" };
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let buf = "";
|
||||
let firstTokenMs: number | null = null;
|
||||
let outputText = "";
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += dec.decode(value, { stream: true });
|
||||
const lines = buf.split("\n");
|
||||
buf = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
const t = line.trim();
|
||||
if (!t.startsWith("data:")) continue;
|
||||
const payload = t.slice(5).trim();
|
||||
if (!payload || payload === "[DONE]") continue;
|
||||
try {
|
||||
const obj = JSON.parse(payload) as { type?: string; delta?: string };
|
||||
if (obj.type === "response.output_text.delta" && obj.delta != null && obj.delta !== "") {
|
||||
if (firstTokenMs == null) firstTokenMs = Math.max(0, Date.now() - started);
|
||||
outputText = appendCap(outputText, obj.delta, STREAM_OUTPUT_CAP);
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
return { firstTokenMs, httpStatus: res.status, outputText };
|
||||
}
|
||||
|
||||
function openAiChatMessageContentToText(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
let s = "";
|
||||
for (const part of content) {
|
||||
if (part && typeof part === "object") {
|
||||
const p = part as { type?: string; text?: string };
|
||||
if (typeof p.text === "string") s += p.text;
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function parseOpenAiChatNonStream(
|
||||
json: unknown,
|
||||
started: number
|
||||
): { firstTokenMs: number | null; outputText: string } {
|
||||
const obj = json as { choices?: Array<{ message?: { content?: unknown } }> };
|
||||
const raw = openAiChatMessageContentToText(obj.choices?.[0]?.message?.content);
|
||||
const outputText = raw.length <= STREAM_OUTPUT_CAP ? raw : raw.slice(0, STREAM_OUTPUT_CAP);
|
||||
const firstTokenMs = outputText !== "" ? Math.max(0, Date.now() - started) : null;
|
||||
return { firstTokenMs, outputText };
|
||||
}
|
||||
|
||||
function extractOpenAiResponsesText(data: unknown): string {
|
||||
if (data == null || typeof data !== "object") return "";
|
||||
const d = data as Record<string, unknown>;
|
||||
if (typeof d.output_text === "string") return d.output_text;
|
||||
|
||||
const out = d.output;
|
||||
if (!Array.isArray(out)) return "";
|
||||
|
||||
const parts: string[] = [];
|
||||
const walk = (node: unknown): void => {
|
||||
if (node == null) return;
|
||||
if (typeof node === "string") {
|
||||
parts.push(node);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
for (const x of node) walk(x);
|
||||
return;
|
||||
}
|
||||
if (typeof node !== "object") return;
|
||||
const o = node as Record<string, unknown>;
|
||||
if (o.type === "output_text" && typeof o.text === "string") {
|
||||
parts.push(o.text);
|
||||
return;
|
||||
}
|
||||
if (typeof o.text === "string" && typeof o.type === "string" && o.type.includes("text")) {
|
||||
parts.push(o.text);
|
||||
return;
|
||||
}
|
||||
if (o.content != null) walk(o.content);
|
||||
if (o.output != null) walk(o.output);
|
||||
};
|
||||
for (const item of out) walk(item);
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function parseOpenAiResponsesNonStream(
|
||||
json: unknown,
|
||||
started: number
|
||||
): { firstTokenMs: number | null; outputText: string } {
|
||||
const raw = extractOpenAiResponsesText(json);
|
||||
const outputText = raw.length <= STREAM_OUTPUT_CAP ? raw : raw.slice(0, STREAM_OUTPUT_CAP);
|
||||
const firstTokenMs = outputText !== "" ? Math.max(0, Date.now() - started) : null;
|
||||
return { firstTokenMs, outputText };
|
||||
}
|
||||
|
||||
function extractClaudeNonStreamText(json: unknown): string {
|
||||
if (json == null || typeof json !== "object") return "";
|
||||
const d = json as { content?: unknown };
|
||||
const content = d.content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
let s = "";
|
||||
for (const block of content) {
|
||||
if (block && typeof block === "object") {
|
||||
const b = block as { type?: string; text?: string };
|
||||
if (b.type === "text" && typeof b.text === "string") s += b.text;
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function parseClaudeNonStream(
|
||||
json: unknown,
|
||||
started: number
|
||||
): { firstTokenMs: number | null; outputText: string } {
|
||||
const raw = extractClaudeNonStreamText(json);
|
||||
const outputText = raw.length <= STREAM_OUTPUT_CAP ? raw : raw.slice(0, STREAM_OUTPUT_CAP);
|
||||
const firstTokenMs = outputText !== "" ? Math.max(0, Date.now() - started) : null;
|
||||
return { firstTokenMs, outputText };
|
||||
}
|
||||
|
||||
/** 首字可来自正文或 thinking;输出只累计 assistant 可见正文 delta.text */
|
||||
function claudeStreamDeltaParts(
|
||||
data: string,
|
||||
currentEvent: string
|
||||
): { outputChunk: string | null; marksFirstToken: boolean } {
|
||||
try {
|
||||
const obj = JSON.parse(data) as {
|
||||
type?: string;
|
||||
delta?: { type?: string; text?: string; thinking?: string };
|
||||
};
|
||||
const match = currentEvent === "content_block_delta" || obj.type === "content_block_delta";
|
||||
if (!match) return { outputChunk: null, marksFirstToken: false };
|
||||
const d = obj.delta;
|
||||
if (!d) return { outputChunk: null, marksFirstToken: false };
|
||||
const hasThinking = typeof d.thinking === "string" && d.thinking !== "";
|
||||
const hasText = typeof d.text === "string" && d.text !== "";
|
||||
return {
|
||||
outputChunk: hasText ? (d.text as string) : null,
|
||||
marksFirstToken: hasThinking || hasText,
|
||||
};
|
||||
} catch {
|
||||
return { outputChunk: null, marksFirstToken: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function readStreamClaude(
|
||||
res: Response,
|
||||
started: number
|
||||
): Promise<{ firstTokenMs: number | null; httpStatus: number; outputText: string }> {
|
||||
if (!res.ok || !res.body) {
|
||||
return { firstTokenMs: null, httpStatus: res.status, outputText: "" };
|
||||
}
|
||||
const reader = res.body.getReader();
|
||||
const dec = new TextDecoder();
|
||||
let buf = "";
|
||||
let currentEvent = "";
|
||||
let firstTokenMs: number | null = null;
|
||||
let outputText = "";
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buf += dec.decode(value, { stream: true });
|
||||
let idx: number;
|
||||
while ((idx = buf.indexOf("\n")) >= 0) {
|
||||
let line = buf.slice(0, idx);
|
||||
buf = buf.slice(idx + 1);
|
||||
if (line.endsWith("\r")) line = line.slice(0, -1);
|
||||
if (line === "") {
|
||||
currentEvent = "";
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith("event:")) {
|
||||
currentEvent = line.slice(6).trim();
|
||||
continue;
|
||||
}
|
||||
if (!line.startsWith("data:")) continue;
|
||||
const data = line.slice(5).trim();
|
||||
const parts = claudeStreamDeltaParts(data, currentEvent);
|
||||
if (parts.marksFirstToken && firstTokenMs == null) {
|
||||
firstTokenMs = Math.max(0, Date.now() - started);
|
||||
}
|
||||
if (parts.outputChunk) {
|
||||
outputText = appendCap(outputText, parts.outputChunk, STREAM_OUTPUT_CAP);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
return { firstTokenMs, httpStatus: res.status, outputText };
|
||||
}
|
||||
|
||||
export type ProbeResult = {
|
||||
ok: boolean;
|
||||
firstTokenMs: number | null;
|
||||
httpStatus: number | null;
|
||||
errorMessage: string | null;
|
||||
requestMessage: string;
|
||||
responseText: string | null;
|
||||
};
|
||||
|
||||
export async function runProbe(params: {
|
||||
apiBaseUrl: string;
|
||||
apiKey: string;
|
||||
model: string;
|
||||
protocol: Protocol;
|
||||
userMessage: string;
|
||||
/** 默认 true(流式);false 时使用非流式 JSON 响应 */
|
||||
stream?: boolean;
|
||||
}): Promise<ProbeResult> {
|
||||
const userMessage = params.userMessage.trim() || "ping";
|
||||
const useStream = params.stream !== false;
|
||||
const ac = new AbortController();
|
||||
const t = setTimeout(() => ac.abort(), PROBE_TIMEOUT_MS);
|
||||
const started = Date.now();
|
||||
try {
|
||||
if (params.protocol === "openai") {
|
||||
const url = openAiChatUrl(params.apiBaseUrl);
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
signal: ac.signal,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: params.model,
|
||||
messages: [{ role: "user", content: userMessage }],
|
||||
max_tokens: PROBE_MAX_TOKENS,
|
||||
stream: useStream,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errBody = await readHttpErrorBody(res);
|
||||
return {
|
||||
ok: false,
|
||||
firstTokenMs: null,
|
||||
httpStatus: res.status,
|
||||
errorMessage: buildHttpErrorMessage(res, errBody),
|
||||
requestMessage: userMessage,
|
||||
responseText: null,
|
||||
};
|
||||
}
|
||||
if (useStream) {
|
||||
const { firstTokenMs, httpStatus, outputText } = await readStreamOpenAIChat(res, started);
|
||||
const ok = firstTokenMs != null;
|
||||
return {
|
||||
ok,
|
||||
firstTokenMs,
|
||||
httpStatus,
|
||||
errorMessage: ok ? null : "no_stream_token",
|
||||
requestMessage: userMessage,
|
||||
responseText: outputText === "" ? null : outputText,
|
||||
};
|
||||
}
|
||||
let json: unknown;
|
||||
try {
|
||||
json = await res.json();
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
firstTokenMs: null,
|
||||
httpStatus: res.status,
|
||||
errorMessage: "invalid_json_body",
|
||||
requestMessage: userMessage,
|
||||
responseText: null,
|
||||
};
|
||||
}
|
||||
const { firstTokenMs, outputText } = parseOpenAiChatNonStream(json, started);
|
||||
const ok = firstTokenMs != null;
|
||||
return {
|
||||
ok,
|
||||
firstTokenMs,
|
||||
httpStatus: res.status,
|
||||
errorMessage: ok ? null : "no_response_content",
|
||||
requestMessage: userMessage,
|
||||
responseText: outputText === "" ? null : outputText,
|
||||
};
|
||||
}
|
||||
if (params.protocol === "openai_responses") {
|
||||
const url = openAiResponsesUrl(params.apiBaseUrl);
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
signal: ac.signal,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: params.model,
|
||||
input: userMessage,
|
||||
stream: useStream,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errBody = await readHttpErrorBody(res);
|
||||
return {
|
||||
ok: false,
|
||||
firstTokenMs: null,
|
||||
httpStatus: res.status,
|
||||
errorMessage: buildHttpErrorMessage(res, errBody),
|
||||
requestMessage: userMessage,
|
||||
responseText: null,
|
||||
};
|
||||
}
|
||||
if (useStream) {
|
||||
const { firstTokenMs, httpStatus, outputText } = await readStreamOpenAIResponses(res, started);
|
||||
const ok = firstTokenMs != null;
|
||||
return {
|
||||
ok,
|
||||
firstTokenMs,
|
||||
httpStatus,
|
||||
errorMessage: ok ? null : "no_stream_token",
|
||||
requestMessage: userMessage,
|
||||
responseText: outputText === "" ? null : outputText,
|
||||
};
|
||||
}
|
||||
let json: unknown;
|
||||
try {
|
||||
json = await res.json();
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
firstTokenMs: null,
|
||||
httpStatus: res.status,
|
||||
errorMessage: "invalid_json_body",
|
||||
requestMessage: userMessage,
|
||||
responseText: null,
|
||||
};
|
||||
}
|
||||
const { firstTokenMs, outputText } = parseOpenAiResponsesNonStream(json, started);
|
||||
const ok = firstTokenMs != null;
|
||||
return {
|
||||
ok,
|
||||
firstTokenMs,
|
||||
httpStatus: res.status,
|
||||
errorMessage: ok ? null : "no_response_content",
|
||||
requestMessage: userMessage,
|
||||
responseText: outputText === "" ? null : outputText,
|
||||
};
|
||||
}
|
||||
const url = claudeUrl(params.apiBaseUrl);
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
signal: ac.signal,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": params.apiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: params.model,
|
||||
max_tokens: PROBE_MAX_TOKENS,
|
||||
messages: [{ role: "user", content: userMessage }],
|
||||
stream: useStream,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errBody = await readHttpErrorBody(res);
|
||||
return {
|
||||
ok: false,
|
||||
firstTokenMs: null,
|
||||
httpStatus: res.status,
|
||||
errorMessage: buildHttpErrorMessage(res, errBody),
|
||||
requestMessage: userMessage,
|
||||
responseText: null,
|
||||
};
|
||||
}
|
||||
if (useStream) {
|
||||
const { firstTokenMs, httpStatus, outputText } = await readStreamClaude(res, started);
|
||||
const ok = firstTokenMs != null;
|
||||
return {
|
||||
ok,
|
||||
firstTokenMs,
|
||||
httpStatus,
|
||||
errorMessage: ok ? null : "no_stream_token",
|
||||
requestMessage: userMessage,
|
||||
responseText: outputText === "" ? null : outputText,
|
||||
};
|
||||
}
|
||||
let json: unknown;
|
||||
try {
|
||||
json = await res.json();
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
firstTokenMs: null,
|
||||
httpStatus: res.status,
|
||||
errorMessage: "invalid_json_body",
|
||||
requestMessage: userMessage,
|
||||
responseText: null,
|
||||
};
|
||||
}
|
||||
const { firstTokenMs, outputText } = parseClaudeNonStream(json, started);
|
||||
const ok = firstTokenMs != null;
|
||||
return {
|
||||
ok,
|
||||
firstTokenMs,
|
||||
httpStatus: res.status,
|
||||
errorMessage: ok ? null : "no_response_content",
|
||||
requestMessage: userMessage,
|
||||
responseText: outputText === "" ? null : outputText,
|
||||
};
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? (e.name === "AbortError" ? "timeout" : e.message) : "unknown_error";
|
||||
return {
|
||||
ok: false,
|
||||
firstTokenMs: null,
|
||||
httpStatus: null,
|
||||
errorMessage: truncateErr(msg),
|
||||
requestMessage: userMessage,
|
||||
responseText: null,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(t);
|
||||
}
|
||||
}
|
||||
|
||||
function truncateErr(s: string, max = 120): string {
|
||||
if (s.length <= max) return s;
|
||||
return s.slice(0, max);
|
||||
}
|
||||
236
worker/scheduler.ts
Normal file
236
worker/scheduler.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { decryptSecret, encryptSecret } from "./crypto";
|
||||
import {
|
||||
deleteMonitor,
|
||||
getAppSettings,
|
||||
getMonitor,
|
||||
insertMonitor,
|
||||
insertProbeEvent,
|
||||
listDueMonitors,
|
||||
pruneProbeEvents,
|
||||
updateMonitorMeta,
|
||||
updateMonitorRunTimes,
|
||||
} from "./db";
|
||||
import { runProbe } from "./probe";
|
||||
|
||||
const PROBE_ERROR_MAX = 8000;
|
||||
const PROBE_IO_FIELD_MAX = 6000;
|
||||
|
||||
const DEFAULT_PROBE_PROMPTS = ["你好", "hello", "ping", "Hi", "测试一下"];
|
||||
|
||||
function parseProbePromptsConfig(raw: string): string[] {
|
||||
const lines = raw.split(/\r?\n/).map((s) => s.trim()).filter((s) => s.length > 0);
|
||||
return lines.length > 0 ? lines : DEFAULT_PROBE_PROMPTS;
|
||||
}
|
||||
|
||||
function pickProbeUserMessage(raw: string): string {
|
||||
const list = parseProbePromptsConfig(raw);
|
||||
const u = new Uint32Array(1);
|
||||
crypto.getRandomValues(u);
|
||||
return list[u[0]! % list.length]!;
|
||||
}
|
||||
|
||||
function truncateProbeError(s: string): string {
|
||||
if (s.length <= PROBE_ERROR_MAX) return s;
|
||||
return s.slice(0, PROBE_ERROR_MAX);
|
||||
}
|
||||
|
||||
function truncateIo(s: string | null): string | null {
|
||||
if (s == null) return null;
|
||||
if (s.length <= PROBE_IO_FIELD_MAX) return s;
|
||||
return s.slice(0, PROBE_IO_FIELD_MAX) + "…";
|
||||
}
|
||||
|
||||
export type RunSingleProbeOutcome = { success: true } | { success: false; error: string };
|
||||
|
||||
export async function runScheduled(env: Env): Promise<void> {
|
||||
const db = env.DB;
|
||||
try {
|
||||
await pruneProbeEvents(db);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
let due: Awaited<ReturnType<typeof listDueMonitors>>;
|
||||
try {
|
||||
due = await listDueMonitors(db, nowSec);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const m of due) {
|
||||
try {
|
||||
await runSingleProbe(env, m.id, db, nowSec);
|
||||
} catch {
|
||||
/* runSingleProbe should not throw; defensive for unexpected runtime errors */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function runSingleProbe(
|
||||
env: Env,
|
||||
monitorId: string,
|
||||
db: D1Database,
|
||||
nowSec: number
|
||||
): Promise<RunSingleProbeOutcome> {
|
||||
const m = await getMonitor(db, monitorId);
|
||||
if (!m || !m.enabled) return { success: true };
|
||||
|
||||
let settings: Awaited<ReturnType<typeof getAppSettings>>;
|
||||
try {
|
||||
settings = await getAppSettings(db);
|
||||
} catch {
|
||||
return { success: false, error: "app_settings_unavailable" };
|
||||
}
|
||||
|
||||
const userMsg = pickProbeUserMessage(settings.probe_prompts ?? "");
|
||||
const intervalMin = settings.probe_interval_minutes;
|
||||
|
||||
const advanceSchedule = async () => {
|
||||
try {
|
||||
const nextRun = nowSec + intervalMin * 60;
|
||||
await updateMonitorRunTimes(db, m.id, nowSec, nextRun);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const recordInfrastructureFailure = async (errorMessage: string) => {
|
||||
try {
|
||||
await insertProbeEvent(db, {
|
||||
monitor_id: m.id,
|
||||
ts: nowSec,
|
||||
ok: 0,
|
||||
first_token_ms: null,
|
||||
http_status: null,
|
||||
error_message: truncateProbeError(errorMessage),
|
||||
probe_input: truncateIo(userMsg),
|
||||
probe_output: null,
|
||||
});
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
await advanceSchedule();
|
||||
};
|
||||
|
||||
try {
|
||||
const apiKey = await decryptSecret(m.api_key_ciphertext, m.api_key_nonce, env);
|
||||
const result = await runProbe({
|
||||
apiBaseUrl: m.api_base_url,
|
||||
apiKey,
|
||||
model: m.model,
|
||||
protocol: m.protocol,
|
||||
userMessage: userMsg,
|
||||
stream: m.probe_stream !== 0,
|
||||
});
|
||||
try {
|
||||
await insertProbeEvent(db, {
|
||||
monitor_id: m.id,
|
||||
ts: nowSec,
|
||||
ok: result.ok ? 1 : 0,
|
||||
first_token_ms: result.firstTokenMs,
|
||||
http_status: result.httpStatus,
|
||||
error_message: result.errorMessage ? truncateProbeError(result.errorMessage) : null,
|
||||
probe_input: truncateIo(result.requestMessage),
|
||||
probe_output: truncateIo(result.responseText),
|
||||
});
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
await advanceSchedule();
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
await advanceSchedule();
|
||||
return { success: true };
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
await recordInfrastructureFailure(msg);
|
||||
return { success: false, error: msg };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createMonitorFromPayload(
|
||||
env: Env,
|
||||
db: D1Database,
|
||||
body: {
|
||||
display_name: string;
|
||||
api_base_url: string;
|
||||
api_key: string;
|
||||
model: string;
|
||||
protocol: "openai" | "openai_responses" | "claude";
|
||||
category?: string;
|
||||
enabled?: boolean;
|
||||
/** 未传时默认流式 */
|
||||
probe_stream?: boolean;
|
||||
/** 未传时默认在首页展示 */
|
||||
show_on_dashboard?: boolean;
|
||||
}
|
||||
): Promise<{ id: string }> {
|
||||
const id = crypto.randomUUID();
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const { ciphertext, nonce } = await encryptSecret(body.api_key, env);
|
||||
const nonceCopy = new Uint8Array(nonce);
|
||||
await insertMonitor(db, {
|
||||
id,
|
||||
display_name: body.display_name.trim(),
|
||||
api_base_url: body.api_base_url.trim(),
|
||||
model: body.model.trim(),
|
||||
protocol: body.protocol,
|
||||
enabled: body.enabled === false ? 0 : 1,
|
||||
category: (body.category ?? "").trim(),
|
||||
created_at: nowSec,
|
||||
api_key_ciphertext: ciphertext,
|
||||
api_key_nonce: nonceCopy.buffer.slice(nonceCopy.byteOffset, nonceCopy.byteOffset + nonceCopy.byteLength),
|
||||
last_run_at: null,
|
||||
next_run_at: nowSec,
|
||||
probe_stream: body.probe_stream === false ? 0 : 1,
|
||||
show_on_dashboard: body.show_on_dashboard === false ? 0 : 1,
|
||||
});
|
||||
return { id };
|
||||
}
|
||||
|
||||
export async function updateMonitorFromPayload(
|
||||
env: Env,
|
||||
db: D1Database,
|
||||
id: string,
|
||||
body: Partial<{
|
||||
display_name: string;
|
||||
api_base_url: string;
|
||||
api_key: string;
|
||||
model: string;
|
||||
protocol: "openai" | "openai_responses" | "claude";
|
||||
category: string;
|
||||
enabled: boolean;
|
||||
probe_stream: boolean;
|
||||
show_on_dashboard: boolean;
|
||||
}>
|
||||
): Promise<boolean> {
|
||||
const cur = await getMonitor(db, id);
|
||||
if (!cur) return false;
|
||||
let ciphertext = cur.api_key_ciphertext as ArrayBuffer;
|
||||
let nonceBuf = cur.api_key_nonce as ArrayBuffer;
|
||||
if (body.api_key != null && body.api_key !== "") {
|
||||
const enc = await encryptSecret(body.api_key, env);
|
||||
const nc = new Uint8Array(enc.nonce);
|
||||
ciphertext = enc.ciphertext;
|
||||
nonceBuf = nc.buffer.slice(nc.byteOffset, nc.byteOffset + nc.byteLength);
|
||||
}
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
await updateMonitorMeta(db, id, {
|
||||
display_name: body.display_name?.trim() ?? cur.display_name,
|
||||
api_base_url: body.api_base_url?.trim() ?? cur.api_base_url,
|
||||
model: body.model?.trim() ?? cur.model,
|
||||
protocol: body.protocol ?? cur.protocol,
|
||||
category: body.category !== undefined ? body.category.trim() : cur.category,
|
||||
enabled: body.enabled != null ? (body.enabled ? 1 : 0) : cur.enabled,
|
||||
api_key_ciphertext: ciphertext,
|
||||
api_key_nonce: nonceBuf,
|
||||
next_run_at: nowSec,
|
||||
probe_stream:
|
||||
body.probe_stream !== undefined ? (body.probe_stream ? 1 : 0) : cur.probe_stream,
|
||||
show_on_dashboard:
|
||||
body.show_on_dashboard !== undefined
|
||||
? (body.show_on_dashboard ? 1 : 0)
|
||||
: cur.show_on_dashboard,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
export { deleteMonitor };
|
||||
Reference in New Issue
Block a user