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
+6
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
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
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
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
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}`;
});
}