beacon-watch
173 行
5.2 KiB
JavaScript
173 行
5.2 KiB
JavaScript
const corsHeaders = {
|
||
"access-control-allow-methods": "GET,POST,OPTIONS",
|
||
"access-control-allow-headers": "content-type",
|
||
"access-control-max-age": "86400"
|
||
};
|
||
const jsonHeaders = { ...corsHeaders, "content-type": "application/json; charset=utf-8" };
|
||
|
||
const SYSTEM_PROMPT =
|
||
"你是「夜航员」,一座沿海灯塔的值班助手。回答准确、克制、有温度,像在雾夜里把航道说清楚。直接回答用户的问题,不要自我介绍,不要使用markdown标题堆砌。";
|
||
|
||
function hasValue(value) {
|
||
return typeof value === "string" && value.trim().length > 0;
|
||
}
|
||
|
||
function requiredEnv(name) {
|
||
const value = process.env[name];
|
||
if (!hasValue(value)) {
|
||
throw new Error(`${name} is not configured`);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
function json(statusCode, body) {
|
||
return { statusCode, headers: jsonHeaders, body: JSON.stringify(body) };
|
||
}
|
||
|
||
function requestPath(event) {
|
||
return (event && (event.path || event.rawPath)) || "/api/cloud/health";
|
||
}
|
||
|
||
function requestMethod(event) {
|
||
return (event && (event.method || event.httpMethod)) || "GET";
|
||
}
|
||
|
||
function parseBody(event) {
|
||
if (!event || !event.body) {
|
||
return {};
|
||
}
|
||
const raw = event.isBase64Encoded ? Buffer.from(event.body, "base64").toString("utf8") : event.body;
|
||
try {
|
||
return JSON.parse(raw);
|
||
} catch (error) {
|
||
const detail = error instanceof Error ? error.message : String(error);
|
||
throw new Error(`invalid JSON body: ${detail}`);
|
||
}
|
||
}
|
||
|
||
function modelConfigured() {
|
||
return (
|
||
hasValue(process.env.OPENAI_BASE_URL) &&
|
||
hasValue(process.env.OPENAI_API_KEY) &&
|
||
!process.env.OPENAI_API_KEY.includes("replace-with") &&
|
||
hasValue(process.env.OPENAI_MODEL)
|
||
);
|
||
}
|
||
|
||
function health() {
|
||
return json(200, {
|
||
ok: true,
|
||
service: "beacon-watch",
|
||
keeper: "夜航员",
|
||
bindings: {
|
||
model: modelConfigured()
|
||
}
|
||
});
|
||
}
|
||
|
||
function normalizeMessages(body) {
|
||
const messages = [];
|
||
if (Array.isArray(body.messages)) {
|
||
for (const item of body.messages) {
|
||
if (!item || (item.role !== "user" && item.role !== "assistant")) {
|
||
continue;
|
||
}
|
||
if (typeof item.content !== "string" || !item.content.trim()) {
|
||
continue;
|
||
}
|
||
messages.push({
|
||
role: item.role,
|
||
content: item.content.trim().slice(0, 4000)
|
||
});
|
||
if (messages.length >= 24) {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (typeof body.question === "string" && body.question.trim()) {
|
||
messages.push({ role: "user", content: body.question.trim().slice(0, 4000) });
|
||
}
|
||
return messages;
|
||
}
|
||
|
||
async function chat(event) {
|
||
const body = parseBody(event);
|
||
const messages = normalizeMessages(body);
|
||
if (!messages.length || messages[messages.length - 1].role !== "user") {
|
||
return json(400, { ok: false, error: "请先写下要问的话" });
|
||
}
|
||
if (!modelConfigured()) {
|
||
return json(503, {
|
||
ok: false,
|
||
error: "值班室还没有接通模型令牌。用带 write:model 权限的 PAT 执行 wehub model token create 后写入函数 env。"
|
||
});
|
||
}
|
||
|
||
const baseURL = requiredEnv("OPENAI_BASE_URL").replace(/\/+$/, "");
|
||
const apiKey = requiredEnv("OPENAI_API_KEY");
|
||
const model = requiredEnv("OPENAI_MODEL");
|
||
const chatURL = baseURL.endsWith("/v1") ? `${baseURL}/chat/completions` : `${baseURL}/v1/chat/completions`;
|
||
|
||
let response;
|
||
try {
|
||
response = await fetch(chatURL, {
|
||
method: "POST",
|
||
headers: { authorization: `Bearer ${apiKey}`, "content-type": "application/json" },
|
||
body: JSON.stringify({
|
||
model,
|
||
messages: [{ role: "system", content: SYSTEM_PROMPT }, ...messages],
|
||
thinking: { type: "disabled" }
|
||
}),
|
||
signal: AbortSignal.timeout(60_000)
|
||
});
|
||
} catch (error) {
|
||
const detail = error instanceof Error ? error.message : String(error);
|
||
return json(502, { ok: false, error: `灯塔链路中断:${detail}` });
|
||
}
|
||
|
||
let payload;
|
||
try {
|
||
payload = await response.json();
|
||
} catch (error) {
|
||
return json(502, { ok: false, error: `模型返回了无法阅读的报文(${response.status})` });
|
||
}
|
||
if (!response.ok) {
|
||
const detail = payload && payload.error && payload.error.message;
|
||
return json(502, { ok: false, error: detail || `模型请求失败:${response.status}` });
|
||
}
|
||
|
||
const answer =
|
||
payload &&
|
||
payload.choices &&
|
||
payload.choices[0] &&
|
||
payload.choices[0].message &&
|
||
payload.choices[0].message.content;
|
||
if (typeof answer !== "string" || !answer.trim()) {
|
||
return json(502, { ok: false, error: "值班室沉默了,没有带回答复" });
|
||
}
|
||
return json(200, { ok: true, answer: answer.trim() });
|
||
}
|
||
|
||
exports.handler = async function handler(event) {
|
||
const path = requestPath(event);
|
||
const method = requestMethod(event).toUpperCase();
|
||
|
||
try {
|
||
if (method === "OPTIONS") {
|
||
return { statusCode: 204, headers: corsHeaders, body: "" };
|
||
}
|
||
if (method === "GET" && path.endsWith("/health")) {
|
||
return health();
|
||
}
|
||
if (method === "POST" && path.endsWith("/chat")) {
|
||
return await chat(event);
|
||
}
|
||
return json(200, {
|
||
ok: true,
|
||
routes: ["GET /api/cloud/health", "POST /api/cloud/chat"]
|
||
});
|
||
} catch (error) {
|
||
return json(500, { ok: false, error: error instanceof Error ? error.message : String(error) });
|
||
}
|
||
};
|