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

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
node_modules/
dist/
.wrangler/
.DS_Store

122
README.MD Normal file
View File

@ -0,0 +1,122 @@
用 Cloudflare Workers也可用于 Pages Functions实现
- KV 作为数据库:以文章相对 URL 存摘要内容
- Workers AI 负责生成摘要
- 纯静态页面HTML + JS抽取指定元素例如 `.post`)的正文,调用 API 生成/读取摘要并展示
- 内置 4 种摘要风格:`tldr` / `bullets` / `outline` / `keywords`
## 目录结构
- `src/worker.ts`Workers APIKV 缓存 + Workers AI 生成)
- `public/index.html`:演示页面(静态)
- `public/summary.js`:前端抽取正文 + 请求摘要 + 渲染
- `public/summary.css`:几种风格的 UI 设计
## 本地开发Pages
1) 安装依赖
```bash
npm i
```
2) 本地启动(带本地 KV/AI 绑定)
```bash
npm run dev
```
打开 Wrangler 输出的本地地址,访问 `/` 即可看到 demo。
> 说明:`npm run dev` 默认使用 `wrangler pages dev public --kv KV --ai AI ...`KV 会持久化到 `.wrangler/state`。
## 部署Pages
```bash
npm run deploy
```
在 Cloudflare Pages 项目设置里添加绑定:
- KV Namespace变量名 `KV`
- AI变量名 `AI`
- 环境变量:`MODEL`(可选,默认 `@cf/meta/llama-3.1-8b-instruct`
> 注意Pages Functions 也是跑在 Workers Runtime 上,调用 `/api/*` 仍会产生函数调用次数;本项目已用 KV 做缓存,只有首次/内容变化才会触发 AI 生成。
部署后:
- 静态页面:`/`
- API
- `GET /api/styles`:返回可用风格
- `GET /api/summary?url=/post/xxx&style=tldr`:读取 KV 缓存(不存在返回 404
- `POST /api/summary`:生成并写入 KV
`POST /api/summary` 请求体示例:
```json
{
"url": "/post/hello",
"style": "bullets",
"text": "这里是文章正文纯文本…"
}
```
## 接入你的静态文章页
你的文章页只需要:
1) 正文容器(可换选择器):
```html
<div class="post">文章内容...</div>
```
2) 放一个摘要组件容器:
```html
<div class="post-summary" data-post-selector=".post" data-style="tldr" data-autoload="false"></div>
```
`data-autoload="false"` 表示不自动请求(避免每次打开页面就触发 `/api/summary`);用户点击风格按钮时才生成/读取。
默认会隐藏下面的摘要内容框(避免占位太大),点击任意风格按钮后才会展开并展示结果。你也可以手动控制初始展开:
```html
<div class="post-summary" data-expanded="true"></div>
```
如果你的站点是深色主题,可以加 `data-theme="dark"`(或强制浅色 `data-theme="light"`
```html
<div class="post-summary" data-post-selector=".post" data-style="tldr" data-theme="dark"></div>
```
3) 引入静态资源:
```html
<link rel="stylesheet" href="/summary.css" />
<script src="/summary.js" type="module"></script>
```
`summary.css` 只会影响 `.post-summary` 组件内部不会污染你站点的全局样式demo 页面用到的全局样式在 `public/demo.css`,集成时不要引用它。
跨域引入说明:
- 如果你的文章站点(如 `https://blog.example.com`)要直接引用另一个域名上的 `summary.js`(并且 `type="module"`),脚本资源必须带 CORS 响应头。
- 本项目已在 Worker`src/worker.ts`)和 Pages`public/_headers`)里为静态资源和 `/api/*` 增加了 `Access-Control-Allow-Origin: *`。
- 默认情况下,组件会把 API 指向 `summary.js` 所在域名(避免跨域脚本被引入时,错误请求到当前文章站点的 `/api/*`)。
- 如果前端和 API 不同域名,可在 `.post-summary` 上设置 `data-api-base="https://你的API域名"` 覆盖。
排查 KV 没写入:
- 打开浏览器 DevTools → Network确认有 `POST https://你的API域名/api/summary`,并且返回里有 `cached: false`
- 访问 `https://你的API域名/api/summary?url=/debug&style=tldr`:未缓存时应返回 404缓存命中才会 200
## 仍想用 Workers可选
如果你仍想用纯 Workers不走 Pages
- `npm run dev:worker`
- `npm run deploy:worker`
- KV id 配置参考 `wrangler.toml`

10
functions/_middleware.ts Normal file
View File

@ -0,0 +1,10 @@
import { handleApi, type ApiEnv } from "../src/api";
export const onRequest: PagesFunction<ApiEnv> = async (context) => {
const url = new URL(context.request.url);
if (url.pathname.startsWith("/api/")) {
return handleApi(context.request, context.env);
}
return context.next();
};

1604
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

17
package.json Normal file
View File

@ -0,0 +1,17 @@
{
"name": "aizhaiyao",
"private": true,
"type": "module",
"scripts": {
"dev": "wrangler pages dev public --compatibility-date=2026-01-05 --kv KV --ai AI -b MODEL=@cf/meta/llama-3.1-8b-instruct",
"deploy": "wrangler pages deploy public --project-name aizhaiyao",
"dev:worker": "wrangler dev",
"deploy:worker": "wrangler deploy",
"typecheck": "tsc --noEmit"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260103.0",
"typescript": "^5.6.3",
"wrangler": "^3.99.0"
}
}

6
public/_headers Normal file
View File

@ -0,0 +1,6 @@
/*
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Headers: Content-Type
Cross-Origin-Resource-Policy: cross-origin

86
public/demo.css Normal file
View File

@ -0,0 +1,86 @@
:root {
--bg: #0b0f1a;
--card: rgba(255, 255, 255, 0.06);
--text: rgba(255, 255, 255, 0.92);
--muted: rgba(255, 255, 255, 0.65);
--border: rgba(255, 255, 255, 0.12);
--shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
--radius: 16px;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
--sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue",
Arial, "Noto Sans", "Liberation Sans", sans-serif;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: var(--sans);
background: radial-gradient(
1200px 700px at 10% 10%,
rgba(124, 92, 255, 0.25),
transparent 60%
),
radial-gradient(
1200px 700px at 90% 20%,
rgba(34, 197, 94, 0.18),
transparent 60%
),
var(--bg);
color: var(--text);
line-height: 1.55;
}
code {
font-family: var(--mono);
font-size: 0.95em;
padding: 0 0.35em;
border: 1px solid var(--border);
border-radius: 8px;
background: rgba(255, 255, 255, 0.06);
}
.page {
max-width: 1100px;
margin: 0 auto;
padding: 32px 16px 60px;
}
.header h1 {
margin: 0 0 8px;
letter-spacing: 0.2px;
}
.demo-muted {
color: var(--muted);
}
.layout {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 16px;
align-items: start;
}
@media (max-width: 900px) {
.layout {
grid-template-columns: 1fr;
}
}
.card {
background: var(--card);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 18px 18px;
backdrop-filter: blur(10px);
}
.post h2 {
margin: 4px 0 10px;
}

47
public/index.html Normal file
View File

@ -0,0 +1,47 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>AI 摘要 Demo</title>
<link rel="stylesheet" href="/demo.css" />
<link rel="stylesheet" href="/summary.css" />
</head>
<body>
<main class="page">
<header class="header">
<h1>AI 摘要 Demo</h1>
<p class="demo-muted">
选择风格后,脚本会读取 <code>.post</code> 内容并调用 <code>/api/summary</code> 获取/生成摘要KV缓存
</p>
</header>
<section class="layout">
<article class="post card">
<h2>示例文章</h2>
<p>
这是一段示例内容:你可以把它替换成你的文章正文。脚本会抽取纯文本并生成不同风格的摘要,比如 TL;DR、要点、结构化等。
</p>
<p>
实际使用时,你的静态页面只要包含 <code>&lt;div class="post"&gt;...&lt;/div&gt;</code> 即可。
</p>
</article>
<aside>
<div class="post-summary" data-post-selector=".post" data-style="tldr">
<div class="post-summary__header">
<div class="post-summary__title">摘要</div>
<div class="post-summary__styles" aria-label="Summary styles"></div>
</div>
<div class="post-summary__body">
<div class="post-summary__status post-summary__muted">等待生成…</div>
<div class="post-summary__content"></div>
</div>
</div>
</aside>
</section>
</main>
<script src="/summary.js" type="module"></script>
</body>
</html>

178
public/summary.css Normal file
View File

@ -0,0 +1,178 @@
.post-summary {
--ps-surface: #ffffff;
--ps-text: #111827;
--ps-muted: #6b7280;
--ps-border: rgba(17, 24, 39, 0.12);
--ps-content-bg: #f8fafc;
--ps-shadow: 0 10px 28px rgba(17, 24, 39, 0.08);
--ps-radius: 16px;
--ps-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
"Courier New", monospace;
--ps-sans: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, "Helvetica Neue",
Arial, "Noto Sans", "Liberation Sans", sans-serif;
--ps-btn-bg: rgba(17, 24, 39, 0.04);
--ps-btn-bg-active: rgba(124, 92, 255, 0.14);
--ps-btn-border-active: rgba(124, 92, 255, 0.55);
--ps-style-tldr: rgba(124, 92, 255, 0.9);
--ps-style-bullets: rgba(34, 197, 94, 0.9);
--ps-style-outline: rgba(245, 158, 11, 0.95);
--ps-style-keywords: rgba(59, 130, 246, 0.9);
box-sizing: border-box;
font-family: var(--ps-sans);
color: var(--ps-text);
line-height: 1.55;
background: var(--ps-surface);
border: 1px solid var(--ps-border);
border-radius: var(--ps-radius);
box-shadow: var(--ps-shadow);
padding: 18px 18px;
backdrop-filter: blur(10px);
display: flex;
flex-direction: column;
gap: 12px;
}
.post-summary[data-theme="dark"] {
--ps-surface: #0b1020;
--ps-text: rgba(255, 255, 255, 0.92);
--ps-muted: rgba(255, 255, 255, 0.65);
--ps-border: rgba(255, 255, 255, 0.14);
--ps-content-bg: rgba(0, 0, 0, 0.22);
--ps-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
--ps-btn-bg: rgba(255, 255, 255, 0.06);
}
.post-summary[data-theme="light"] {
--ps-surface: #ffffff;
--ps-text: #111827;
--ps-muted: #6b7280;
--ps-border: rgba(17, 24, 39, 0.12);
--ps-content-bg: #f8fafc;
--ps-shadow: 0 10px 28px rgba(17, 24, 39, 0.08);
--ps-btn-bg: rgba(17, 24, 39, 0.04);
}
@media (prefers-color-scheme: dark) {
.post-summary:not([data-theme="light"]) {
--ps-surface: #0b1020;
--ps-text: rgba(255, 255, 255, 0.92);
--ps-muted: rgba(255, 255, 255, 0.65);
--ps-border: rgba(255, 255, 255, 0.14);
--ps-content-bg: rgba(0, 0, 0, 0.22);
--ps-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
--ps-btn-bg: rgba(255, 255, 255, 0.06);
}
}
.post-summary :where(*, *::before, *::after) {
box-sizing: border-box;
}
.post-summary :where(code) {
font-family: var(--ps-mono);
font-size: 0.95em;
padding: 0 0.35em;
border: 1px solid var(--ps-border);
border-radius: 8px;
background: rgba(127, 127, 127, 0.12);
}
.post-summary__muted {
color: var(--ps-muted);
}
.post-summary__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.post-summary__title {
font-weight: 650;
letter-spacing: 0.2px;
}
.post-summary__styles {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: flex-end;
}
.post-summary__styles button {
appearance: none;
border: 1px solid var(--ps-border);
background: var(--ps-btn-bg);
color: var(--ps-text);
padding: 6px 10px;
border-radius: 999px;
cursor: pointer;
font-size: 13px;
}
.post-summary__styles button[aria-pressed="true"] {
border-color: var(--ps-btn-border-active);
background: var(--ps-btn-bg-active);
}
.post-summary__body {
display: grid;
gap: 10px;
}
.post-summary__status {
font-size: 13px;
}
.post-summary__content {
padding: 14px 14px;
border: 1px solid var(--ps-border);
border-radius: 14px;
background: var(--ps-content-bg);
min-height: 120px;
white-space: pre-wrap;
}
.post-summary:not([data-expanded="true"]) .post-summary__content {
display: none;
}
.post-summary__content :where(ul) {
margin: 0;
padding-left: 18px;
display: grid;
gap: 6px;
}
.post-summary__content :where(h4) {
margin: 10px 0 6px;
font-size: 14px;
letter-spacing: 0.2px;
}
.post-summary__content :where(p) {
margin: 0 0 8px;
color: var(--ps-text);
}
.post-summary[data-style="tldr"] .post-summary__content {
border-left: 4px solid var(--ps-style-tldr);
}
.post-summary[data-style="bullets"] .post-summary__content {
border-left: 4px solid var(--ps-style-bullets);
}
.post-summary[data-style="outline"] .post-summary__content {
border-left: 4px solid var(--ps-style-outline);
}
.post-summary[data-style="keywords"] .post-summary__content {
border-left: 4px solid var(--ps-style-keywords);
font-family: var(--ps-mono);
}

208
public/summary.js Normal file
View File

@ -0,0 +1,208 @@
const DEFAULT_POST_SELECTOR = ".post";
const DEFAULT_STYLE = "tldr";
function $(root, selector) {
const el = root.querySelector(selector);
if (!el) throw new Error(`Missing element: ${selector}`);
return el;
}
function compactText(text) {
return text.replace(/\s+/g, " ").trim();
}
function escapeHtml(s) {
return s
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
async function fetchJson(url, init) {
const res = await fetch(url, init);
const data = await res.json().catch(() => ({}));
return { res, data };
}
function renderMarkdownish(container, style, summary) {
if (style === "bullets") {
const lines = summary
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
const items = lines
.map((l) => (l.startsWith("-") ? l.replace(/^-+\s*/, "") : l))
.filter(Boolean);
container.innerHTML = `<ul>${items
.map((i) => `<li>${escapeHtml(i)}</li>`)
.join("")}</ul>`;
return;
}
if (style === "outline") {
const blocks = summary.split("\n");
const html = [];
for (const line of blocks) {
const l = line.trimEnd();
if (!l) continue;
if (l.startsWith("###")) {
html.push(`<h4>${escapeHtml(l.replace(/^###\s*/, ""))}</h4>`);
} else {
html.push(`<p>${escapeHtml(l)}</p>`);
}
}
container.innerHTML = html.join("");
return;
}
container.textContent = summary;
}
async function ensureSummary(root) {
const widget = root;
const title = widget.dataset.title || "摘要";
const autoload =
widget.dataset.autoload !== "false" && widget.dataset.autoload !== "0";
if (widget.dataset.expanded == null) widget.dataset.expanded = "false";
const needsScaffold =
!widget.querySelector(".post-summary__status") ||
!widget.querySelector(".post-summary__content") ||
!widget.querySelector(".post-summary__styles");
if (needsScaffold) {
widget.innerHTML = `
<div class="post-summary__header">
<div class="post-summary__title"></div>
<div class="post-summary__styles" aria-label="Summary styles"></div>
</div>
<div class="post-summary__body">
<div class="post-summary__status post-summary__muted">等待生成</div>
<div class="post-summary__content"></div>
</div>
`.trim();
const titleEl = widget.querySelector(".post-summary__title");
if (titleEl) titleEl.textContent = title;
} else {
const titleEl = widget.querySelector(".post-summary__title");
if (titleEl && !titleEl.textContent) titleEl.textContent = title;
}
const postSelector = widget.dataset.postSelector || DEFAULT_POST_SELECTOR;
const inferredBase = (() => {
try {
return new URL(import.meta.url).origin;
} catch {
return "";
}
})();
const apiBase = (widget.dataset.apiBase || inferredBase).replace(/\/$/, "");
const urlKey = widget.dataset.url || location.pathname + location.search;
const statusEl = $(widget, ".post-summary__status");
const contentEl = $(widget, ".post-summary__content");
const stylesEl = $(widget, ".post-summary__styles");
const updatePressed = (style) => {
for (const btn of stylesEl.querySelectorAll("button[data-style]")) {
btn.setAttribute(
"aria-pressed",
btn.dataset.style === style ? "true" : "false"
);
}
};
const setStyle = async (style) => {
widget.dataset.expanded = "true";
widget.dataset.style = style;
updatePressed(style);
statusEl.textContent = "读取缓存…";
contentEl.textContent = "";
const { res, data } = await fetchJson(
`${apiBase}/api/summary?url=${encodeURIComponent(urlKey)}&style=${encodeURIComponent(
style
)}`
);
if (res.ok && typeof data?.summary === "string" && data.summary) {
statusEl.textContent = data.cached ? "来自 KV 缓存" : "已生成";
renderMarkdownish(contentEl, style, data.summary || "");
return;
}
const isCacheMiss = res.status === 404 || data?.error === "Not found";
if (!isCacheMiss) {
statusEl.textContent = `请求失败:${res.status}`;
contentEl.textContent = data?.error || "Unknown error";
return;
}
const postEl = document.querySelector(postSelector);
if (!postEl) {
statusEl.textContent = `未找到文章元素:${postSelector}`;
return;
}
const rawText = compactText(postEl.innerText || postEl.textContent || "");
if (!rawText) {
statusEl.textContent = "文章内容为空";
return;
}
const MAX_CHARS = 20000;
const text = rawText.slice(0, MAX_CHARS);
statusEl.textContent = "AI 生成中…";
const created = await fetchJson(`${apiBase}/api/summary`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: urlKey, style, text }),
});
if (!created.res.ok) {
statusEl.textContent = `生成失败:${created.res.status}`;
contentEl.textContent = created.data?.error || "Unknown error";
return;
}
statusEl.textContent = created.data.cached ? "来自 KV 缓存" : "已生成";
renderMarkdownish(contentEl, style, created.data.summary || "");
};
stylesEl.innerHTML = "";
const styleList = [
{ key: "tldr", label: "TL;DR" },
{ key: "bullets", label: "要点" },
{ key: "outline", label: "结构化" },
{ key: "keywords", label: "关键词" },
];
for (const { key, label } of styleList) {
const btn = document.createElement("button");
btn.type = "button";
btn.dataset.style = key;
btn.textContent = label;
btn.addEventListener("click", () => setStyle(key));
stylesEl.appendChild(btn);
}
const initialStyle = widget.dataset.style || DEFAULT_STYLE;
widget.dataset.style = initialStyle;
updatePressed(initialStyle);
if (autoload) {
widget.dataset.expanded = "true";
await setStyle(initialStyle);
} else {
widget.dataset.expanded = "false";
statusEl.textContent = "点击上方风格生成摘要…";
contentEl.textContent = "";
}
}
for (const widget of document.querySelectorAll(".post-summary")) {
ensureSummary(widget).catch((err) => {
const statusEl = widget.querySelector(".post-summary__status");
if (statusEl) statusEl.textContent = `初始化失败:${err.message || err}`;
});
}

197
src/api.ts Normal file
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
src/styles.ts Normal file
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
src/worker.ts Normal file
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 }));
},
};

14
tsconfig.json Normal file
View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "WebWorker"],
"module": "ES2022",
"moduleResolution": "Bundler",
"strict": true,
"skipLibCheck": true,
"types": ["@cloudflare/workers-types"],
"noEmit": true
},
"include": ["src/**/*.ts"]
}

28
wrangler.toml Normal file
View File

@ -0,0 +1,28 @@
name = "aizhaiyao"
main = "src/worker.ts"
compatibility_date = "2026-01-05"
[vars]
# Workers AI model id
MODEL = "@cf/meta/llama-3.1-8b-instruct"
[ai]
binding = "AI"
[assets]
directory = "./public"
binding = "ASSETS"
# 1) Create KV namespace (replace ids below):
# wrangler kv namespace create KV
# wrangler kv namespace create KV --preview
#
# 2) Then uncomment and fill the ids:
# [[kv_namespaces]]
# binding = "KV"
# id = "REPLACE_WITH_ID"
# preview_id = "REPLACE_WITH_PREVIEW_ID"
[[kv_namespaces]]
binding = "KV"
id = "5feef48eef8b43bbbb3487b7a1982fb4"