first commit
This commit is contained in:
commit
2d8303808d
|
|
@ -0,0 +1,5 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.wrangler/
|
||||
.DS_Store
|
||||
|
||||
|
|
@ -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 API(KV 缓存 + 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`
|
||||
|
|
@ -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();
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
@ -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><div class="post">...</div></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>
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
}
|
||||
|
||||
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}`;
|
||||
});
|
||||
}
|
||||
|
|
@ -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 }));
|
||||
},
|
||||
};
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
||||
|
|
@ -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"
|
||||
Loading…
Reference in New Issue