first commit
This commit is contained in:
@@ -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}`;
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user