first commit

This commit is contained in:
浪子
2026-01-05 19:51:09 +08:00
commit 2d8303808d
15 changed files with 2596 additions and 0 deletions
+197
View File
@@ -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 });
}
+42
View File
@@ -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";
}
+32
View File
@@ -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 }));
},
};