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