first commit
This commit is contained in:
+197
@@ -0,0 +1,197 @@
|
||||
import { parseStyle, STYLES, type SummaryStyle } from "./styles";
|
||||
|
||||
export type AiBinding = { run: (model: string, input: unknown) => Promise<unknown> };
|
||||
|
||||
export type ApiEnv = {
|
||||
KV?: KVNamespace;
|
||||
AI?: AiBinding;
|
||||
MODEL?: string;
|
||||
};
|
||||
|
||||
export type SummaryRecord = {
|
||||
url: string;
|
||||
style: SummaryStyle;
|
||||
summary: string;
|
||||
sourceHash: string;
|
||||
model: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
const MAX_INPUT_CHARS = 20_000;
|
||||
const MAX_URL_CHARS = 500;
|
||||
|
||||
function withCors(response: Response): Response {
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set("Access-Control-Allow-Origin", "*");
|
||||
headers.set("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
|
||||
headers.set("Access-Control-Allow-Headers", "Content-Type");
|
||||
headers.set("Access-Control-Max-Age", "86400");
|
||||
headers.set("Vary", "Origin");
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
function json(data: unknown, init: ResponseInit = {}): Response {
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set("Content-Type", "application/json; charset=utf-8");
|
||||
return withCors(new Response(JSON.stringify(data, null, 2), { ...init, headers }));
|
||||
}
|
||||
|
||||
function badRequest(message: string, details?: unknown): Response {
|
||||
return json({ error: message, details }, { status: 400 });
|
||||
}
|
||||
|
||||
function serverError(message: string, details?: unknown): Response {
|
||||
return json({ error: message, details }, { status: 500 });
|
||||
}
|
||||
|
||||
function normalizeRelativeUrl(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return "/";
|
||||
try {
|
||||
const u = new URL(trimmed);
|
||||
return (u.pathname + u.search).slice(0, MAX_URL_CHARS) || "/";
|
||||
} catch {
|
||||
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
return withSlash.slice(0, MAX_URL_CHARS);
|
||||
}
|
||||
}
|
||||
|
||||
function base64Url(bytes: ArrayBuffer): string {
|
||||
const bin = String.fromCharCode(...new Uint8Array(bytes));
|
||||
const b64 = btoa(bin);
|
||||
return b64.replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
|
||||
}
|
||||
|
||||
async function sha256(text: string): Promise<string> {
|
||||
const data = new TextEncoder().encode(text);
|
||||
const digest = await crypto.subtle.digest("SHA-256", data);
|
||||
return base64Url(digest);
|
||||
}
|
||||
|
||||
function kvKey(url: string, style: SummaryStyle): string {
|
||||
return `summary:${style}:${encodeURIComponent(url)}`;
|
||||
}
|
||||
|
||||
async function readJson<T>(request: Request): Promise<T> {
|
||||
const contentType = request.headers.get("Content-Type") || "";
|
||||
if (!contentType.toLowerCase().includes("application/json")) {
|
||||
throw new Error("Content-Type must be application/json");
|
||||
}
|
||||
return (await request.json()) as T;
|
||||
}
|
||||
|
||||
export async function handleApi(request: Request, env: ApiEnv): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (request.method === "OPTIONS") return withCors(new Response(null, { status: 204 }));
|
||||
|
||||
if (url.pathname === "/api/styles" && request.method === "GET") {
|
||||
return json({
|
||||
styles: Object.entries(STYLES).map(([key, spec]) => ({
|
||||
key,
|
||||
label: spec.label,
|
||||
maxCharsHint: spec.maxCharsHint,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/summary" && request.method === "GET") {
|
||||
if (!env.KV) return serverError("KV binding not configured (KV).");
|
||||
const relativeUrl = normalizeRelativeUrl(url.searchParams.get("url") || "/");
|
||||
const style = parseStyle(url.searchParams.get("style"));
|
||||
const record = (await env.KV.get(kvKey(relativeUrl, style), {
|
||||
type: "json",
|
||||
})) as SummaryRecord | null;
|
||||
if (!record) return json({ error: "Not found" }, { status: 404 });
|
||||
return json({ ...record, cached: true });
|
||||
}
|
||||
|
||||
if (url.pathname === "/api/summary" && request.method === "POST") {
|
||||
if (!env.KV) return serverError("KV binding not configured (KV).");
|
||||
if (!env.AI?.run) return serverError("Workers AI binding not configured (AI).");
|
||||
|
||||
type Body = {
|
||||
url: string;
|
||||
style?: string;
|
||||
text: string;
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
let body: Body;
|
||||
try {
|
||||
body = await readJson<Body>(request);
|
||||
} catch (err) {
|
||||
return badRequest((err as Error).message);
|
||||
}
|
||||
|
||||
if (!body?.url) return badRequest("Missing 'url'.");
|
||||
if (!body?.text) return badRequest("Missing 'text'.");
|
||||
|
||||
const relativeUrl = normalizeRelativeUrl(body.url);
|
||||
const style = parseStyle(body.style ?? null);
|
||||
const text = body.text.trim().slice(0, MAX_INPUT_CHARS);
|
||||
const sourceHash = await sha256(text);
|
||||
|
||||
const key = kvKey(relativeUrl, style);
|
||||
const existing = (await env.KV.get(key, { type: "json" })) as SummaryRecord | null;
|
||||
if (existing && existing.sourceHash === sourceHash && !body.force) {
|
||||
return json({ ...existing, cached: true });
|
||||
}
|
||||
|
||||
const model = env.MODEL || "@cf/meta/llama-3.1-8b-instruct";
|
||||
const spec = STYLES[style];
|
||||
|
||||
const messages = [
|
||||
{
|
||||
role: "system" as const,
|
||||
content:
|
||||
"你是一个中文摘要助手。输入是网页文章正文的纯文本(可能包含少量导航/广告残留)。" +
|
||||
"输出必须严格遵循用户指定的风格要求。不要编造信息;遇到缺失信息就写“文中未提及”。" +
|
||||
"不要输出与摘要无关的前后缀(例如:'好的,以下是摘要')。",
|
||||
},
|
||||
{
|
||||
role: "user" as const,
|
||||
content:
|
||||
`风格:${style}(${spec.label})\n` +
|
||||
`要求:${spec.instructions}\n\n` +
|
||||
`文章URL:${relativeUrl}\n` +
|
||||
"正文:\n" +
|
||||
text,
|
||||
},
|
||||
];
|
||||
|
||||
let summaryText: string | undefined;
|
||||
try {
|
||||
const result = (await env.AI.run(model, {
|
||||
messages,
|
||||
temperature: 0.2,
|
||||
})) as { response?: string };
|
||||
summaryText = result?.response?.trim();
|
||||
} catch (err) {
|
||||
return serverError("AI generation failed.", { message: (err as Error).message });
|
||||
}
|
||||
|
||||
if (!summaryText) return serverError("AI returned empty response.");
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const record: SummaryRecord = {
|
||||
url: relativeUrl,
|
||||
style,
|
||||
summary: summaryText,
|
||||
sourceHash,
|
||||
model,
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await env.KV.put(key, JSON.stringify(record));
|
||||
return json({ ...record, cached: false });
|
||||
}
|
||||
|
||||
return json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
export type SummaryStyle = "tldr" | "bullets" | "outline" | "keywords";
|
||||
|
||||
export type StyleSpec = {
|
||||
label: string;
|
||||
instructions: string;
|
||||
maxCharsHint: number;
|
||||
};
|
||||
|
||||
export const STYLES: Record<SummaryStyle, StyleSpec> = {
|
||||
tldr: {
|
||||
label: "TL;DR",
|
||||
instructions:
|
||||
"用中文输出 2-3 句话的摘要,先给结论再给理由;尽量具体,避免空话;不要输出标题或列表。",
|
||||
maxCharsHint: 450,
|
||||
},
|
||||
bullets: {
|
||||
label: "要点",
|
||||
instructions:
|
||||
"用中文输出 5-8 条要点,每条 15-35 字,使用 Markdown 列表(以 '- ' 开头);不要重复句式;不要输出多余解释。",
|
||||
maxCharsHint: 650,
|
||||
},
|
||||
outline: {
|
||||
label: "结构化",
|
||||
instructions:
|
||||
"用中文输出 3-6 个小节,每小节一个短标题(不超过 10 字)+ 1-2 句说明;使用 Markdown:'### 标题' 换行后写内容。",
|
||||
maxCharsHint: 900,
|
||||
},
|
||||
keywords: {
|
||||
label: "关键词",
|
||||
instructions:
|
||||
"用中文输出 8-12 个关键词/短语,用顿号或逗号分隔;不加编号、不加解释、不要换行。",
|
||||
maxCharsHint: 200,
|
||||
},
|
||||
};
|
||||
|
||||
export function parseStyle(value: string | null): SummaryStyle {
|
||||
if (!value) return "tldr";
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (normalized in STYLES) return normalized as SummaryStyle;
|
||||
return "tldr";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { handleApi, type ApiEnv } from "./api";
|
||||
|
||||
type Env = ApiEnv & {
|
||||
ASSETS?: Fetcher;
|
||||
};
|
||||
|
||||
function withCors(response: Response): Response {
|
||||
const headers = new Headers(response.headers);
|
||||
headers.set("Access-Control-Allow-Origin", "*");
|
||||
headers.set("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
|
||||
headers.set("Access-Control-Allow-Headers", "Content-Type");
|
||||
headers.set("Access-Control-Max-Age", "86400");
|
||||
headers.set("Cross-Origin-Resource-Policy", "cross-origin");
|
||||
headers.set("Vary", "Origin");
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname.startsWith("/api/")) return handleApi(request, env);
|
||||
if (env.ASSETS) {
|
||||
const res = await env.ASSETS.fetch(request);
|
||||
return withCors(res);
|
||||
}
|
||||
return withCors(new Response("Not found", { status: 404 }));
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user