Add versioned annotation submissions

This commit is contained in:
wuyang6
2026-07-20 17:28:47 +08:00
parent 7cd6e5e464
commit 97e1e1dd48
4 changed files with 549 additions and 3 deletions
+43 -2
View File
@@ -14,8 +14,8 @@ export async function GET(
return new Response("Invalid annotation page", { status: 400 }); return new Response("Invalid annotation page", { status: 400 });
try { try {
const content = await readFile(filePath); const content = await readFile(filePath, "utf8");
return new Response(content, { return new Response(injectSubmissionBridge(content, name), {
headers: { headers: {
"cache-control": "no-store", "cache-control": "no-store",
"content-type": "text/html; charset=utf-8", "content-type": "text/html; charset=utf-8",
@@ -26,3 +26,44 @@ export async function GET(
return new Response("Annotation page not found", { status: 404 }); return new Response("Annotation page not found", { status: 404 });
} }
} }
function injectSubmissionBridge(content: string, pageName: string) {
if (!content.includes("</body>") || !content.includes("mergedRows")) {
return content;
}
const safePageName = JSON.stringify(pageName).replaceAll("<", "\\u003c");
const bridge = `
<script>
window.__annotationSubmissionBridge = {
pageName: ${safePageName},
read: () => mergedRows().map(row => ({
requestId: String(row.request_id || ""),
result: String(row.GSB || ""),
note: String(row["人工备注"] || "")
})),
apply: items => {
for (const row of rows || []) {
if (row?.request_id) delete saved[row.request_id];
}
for (const item of items || []) {
if (!item || !item.requestId) continue;
saved[item.requestId] = { GSB: item.result || "", note: item.note || "" };
}
persist();
render();
},
applyMissing: items => {
for (const item of items || []) {
if (!item || !item.requestId || saved[item.requestId]) continue;
saved[item.requestId] = { GSB: item.result || "", note: item.note || "" };
}
persist();
render();
},
notify: message => toast(message)
};
</script>
<script src="/annotation-submissions.js"></script>
`;
return content.replace(/<\/body>/i, `${bridge}</body>`);
}
@@ -0,0 +1,62 @@
import {
createAnnotationSubmission,
readAnnotationSubmission,
readAnnotationSubmissions,
} from "@/lib/annotation-pages";
import { getCurrentAccount } from "@/lib/claw-auth";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export async function GET(
request: Request,
{ params }: { params: Promise<{ name: string }> },
) {
try {
const { name } = await params;
const revisionId = new URL(request.url).searchParams.get("revision");
if (revisionId) {
const submission = await readAnnotationSubmission(name, revisionId);
if (!submission) return errorResponse("提交记录不存在", 404);
return jsonResponse({ submission });
}
return jsonResponse(await readAnnotationSubmissions(name));
} catch (error) {
return errorResponse(errorMessage(error), 404);
}
}
export async function POST(
request: Request,
{ params }: { params: Promise<{ name: string }> },
) {
const account = await getCurrentAccount();
if (!account) return errorResponse("请先登录工作台再提交", 401);
try {
const { name } = await params;
const body = (await request.json()) as { annotations?: unknown };
const submission = await createAnnotationSubmission(
name,
account,
body.annotations,
);
return jsonResponse({ submission }, 201);
} catch (error) {
return errorResponse(errorMessage(error), 400);
}
}
function jsonResponse(value: unknown, status = 200) {
return Response.json(value, {
status,
headers: { "cache-control": "no-store" },
});
}
function errorResponse(error: string, status: number) {
return jsonResponse({ error }, status);
}
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : "操作失败";
}
+210 -1
View File
@@ -1,4 +1,13 @@
import { mkdir, open, readdir, stat } from "node:fs/promises"; import { createHash, randomUUID } from "node:crypto";
import {
mkdir,
open,
readdir,
readFile,
rename,
stat,
writeFile,
} from "node:fs/promises";
import path from "node:path"; import path from "node:path";
export type AnnotationPageInfo = { export type AnnotationPageInfo = {
@@ -9,9 +18,43 @@ export type AnnotationPageInfo = {
href: string; href: string;
}; };
export type AnnotationValue = {
requestId: string;
result: "G" | "S" | "B" | "";
note: string;
};
export type AnnotationSubmission = {
id: string;
version: number;
pageName: string;
submittedAt: string;
submittedBy: { id: string; username: string };
summary: {
total: number;
completed: number;
G: number;
S: number;
B: number;
};
annotations: AnnotationValue[];
};
export type AnnotationSubmissionSummary = Omit<
AnnotationSubmission,
"annotations"
>;
const annotationPagesRoot = path.resolve( const annotationPagesRoot = path.resolve(
/* turbopackIgnore: true */
process.env.ANNOTATION_PAGES_DIR?.trim() || "/home/mi/annotation-pages", process.env.ANNOTATION_PAGES_DIR?.trim() || "/home/mi/annotation-pages",
); );
const annotationSubmissionsRoot = path.resolve(
/* turbopackIgnore: true */
process.env.ANNOTATION_SUBMISSIONS_DIR?.trim() ||
path.join(annotationPagesRoot, ".submissions"),
);
const submissionWriteQueues = new Map<string, Promise<unknown>>();
export async function listAnnotationPages(): Promise<AnnotationPageInfo[]> { export async function listAnnotationPages(): Promise<AnnotationPageInfo[]> {
await mkdir(annotationPagesRoot, { recursive: true }); await mkdir(annotationPagesRoot, { recursive: true });
@@ -49,6 +92,85 @@ export function resolveAnnotationPage(name: string): string | null {
return resolved; return resolved;
} }
export async function readAnnotationSubmissions(pageName: string) {
const pagePath = resolveAnnotationPage(pageName);
if (!pagePath) throw new Error("无效的标注页面");
await stat(pagePath);
const pageRoot = submissionPageRoot(pageName);
const latest = await readSubmissionFile(path.join(pageRoot, "latest.json"));
const revisionsRoot = path.join(pageRoot, "revisions");
let names: string[] = [];
try {
names = await readdir(revisionsRoot);
} catch {
return { latest, history: [] as AnnotationSubmissionSummary[] };
}
const history = (
await Promise.all(
names
.filter((name) => name.endsWith(".json"))
.map((name) => readSubmissionFile(path.join(revisionsRoot, name))),
)
)
.filter((item): item is AnnotationSubmission => item !== null)
.sort((left, right) => right.version - left.version)
.slice(0, 100)
.map(withoutAnnotations);
return { latest, history };
}
export async function readAnnotationSubmission(
pageName: string,
submissionId: string,
) {
if (!/^[a-zA-Z0-9_-]{8,120}$/.test(submissionId)) return null;
const pagePath = resolveAnnotationPage(pageName);
if (!pagePath) throw new Error("无效的标注页面");
await stat(pagePath);
return readSubmissionFile(
path.join(
submissionPageRoot(pageName),
"revisions",
`${submissionId}.json`,
),
);
}
export async function createAnnotationSubmission(
pageName: string,
account: { id: string; username: string },
input: unknown,
) {
const pagePath = resolveAnnotationPage(pageName);
if (!pagePath) throw new Error("无效的标注页面");
await stat(pagePath);
const annotations = normalizeAnnotations(input);
return enqueueSubmissionWrite(pageName, async () => {
const pageRoot = submissionPageRoot(pageName);
const revisionsRoot = path.join(pageRoot, "revisions");
await mkdir(revisionsRoot, { recursive: true });
const latest = await readSubmissionFile(path.join(pageRoot, "latest.json"));
const submittedAt = new Date().toISOString();
const id = `${submittedAt.replace(/\D/g, "").slice(0, 17)}-${randomUUID().slice(0, 8)}`;
const submission: AnnotationSubmission = {
id,
version: (latest?.version ?? 0) + 1,
pageName,
submittedAt,
submittedBy: account,
summary: summarizeAnnotations(annotations),
annotations,
};
await atomicWriteJson(path.join(revisionsRoot, `${id}.json`), submission);
await atomicWriteJson(path.join(pageRoot, "latest.json"), submission);
return submission;
});
}
function isSafeHtmlName(name: string): boolean { function isSafeHtmlName(name: string): boolean {
return ( return (
name === path.basename(name) && name === path.basename(name) &&
@@ -57,6 +179,93 @@ function isSafeHtmlName(name: string): boolean {
); );
} }
function submissionPageRoot(pageName: string) {
const digest = createHash("sha256")
.update(pageName)
.digest("hex")
.slice(0, 24);
return path.join(annotationSubmissionsRoot, digest);
}
function normalizeAnnotations(input: unknown): AnnotationValue[] {
if (!Array.isArray(input)) throw new Error("annotations 必须是数组");
if (input.length > 20_000) throw new Error("单次提交最多包含 20000 条标注");
const seen = new Set<string>();
return input.map((raw, index) => {
if (!raw || typeof raw !== "object") {
throw new Error(`${index + 1} 条标注格式错误`);
}
const value = raw as Record<string, unknown>;
const requestId = String(value.requestId ?? value.request_id ?? "").trim();
const result = String(value.result ?? value.GSB ?? "").trim();
const note = String(value.note ?? value. ?? "").trim();
if (!requestId || requestId.length > 200) {
throw new Error(`${index + 1} 条缺少有效 request_id`);
}
if (seen.has(requestId)) throw new Error(`request_id 重复:${requestId}`);
if (!["", "G", "S", "B"].includes(result)) {
throw new Error(`${index + 1} 条 GSB 只能是 G、S、B 或空`);
}
if (note.length > 5000) throw new Error(`${index + 1} 条备注过长`);
seen.add(requestId);
return {
requestId,
result: result as AnnotationValue["result"],
note,
};
});
}
function summarizeAnnotations(annotations: AnnotationValue[]) {
const summary = {
total: annotations.length,
completed: 0,
G: 0,
S: 0,
B: 0,
};
for (const annotation of annotations) {
if (!annotation.result) continue;
summary.completed += 1;
summary[annotation.result] += 1;
}
return summary;
}
async function readSubmissionFile(filePath: string) {
try {
return JSON.parse(await readFile(filePath, "utf8")) as AnnotationSubmission;
} catch {
return null;
}
}
function withoutAnnotations(
submission: AnnotationSubmission,
): AnnotationSubmissionSummary {
const { annotations: _annotations, ...summary } = submission;
return summary;
}
async function atomicWriteJson(filePath: string, value: unknown) {
const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
await writeFile(temporaryPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
await rename(temporaryPath, filePath);
}
function enqueueSubmissionWrite<T>(pageName: string, task: () => Promise<T>) {
const previous = submissionWriteQueues.get(pageName) ?? Promise.resolve();
const current = previous.then(task, task);
submissionWriteQueues.set(pageName, current);
const cleanup = () => {
if (submissionWriteQueues.get(pageName) === current) {
submissionWriteQueues.delete(pageName);
}
};
void current.then(cleanup, cleanup);
return current;
}
async function readHtmlTitle(filePath: string, fileName: string) { async function readHtmlTitle(filePath: string, fileName: string) {
const handle = await open(filePath, "r"); const handle = await open(filePath, "r");
try { try {
@@ -0,0 +1,234 @@
(() => {
const bridge = window.__annotationSubmissionBridge;
if (!bridge?.pageName || typeof bridge.read !== "function") return;
const endpoint = `/api/annotations/${encodeURIComponent(bridge.pageName)}/submissions`;
const toolbar = document.querySelector(".toolbar");
if (!toolbar) return;
const style = document.createElement("style");
style.textContent = `
.annotation-submit-status { color: #687385; font-size: 12px; white-space: nowrap; }
.annotation-history-dialog { width: min(680px, calc(100vw - 32px)); max-height: min(720px, calc(100vh - 32px)); padding: 0; border: 1px solid #d9dee7; border-radius: 8px; box-shadow: 0 24px 80px rgba(15, 23, 42, .2); }
.annotation-history-dialog::backdrop { background: rgba(15, 23, 42, .32); }
.annotation-history-head { position: sticky; top: 0; z-index: 1; display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 16px 18px; border-bottom: 1px solid #e5e9ef; background: #fff; }
.annotation-history-head h2 { margin: 0; font-size: 17px; }
.annotation-history-close { width: 32px; height: 32px; border: 0; border-radius: 5px; background: #f1f4f8; font-size: 20px; color: #556274; }
.annotation-history-list { margin: 0; padding: 0; list-style: none; overflow: auto; }
.annotation-history-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 14px; align-items: center; padding: 14px 18px; border-bottom: 1px solid #edf0f4; }
.annotation-history-item:last-child { border-bottom: 0; }
.annotation-history-title { font-weight: 650; font-size: 14px; }
.annotation-history-meta { margin-top: 5px; color: #687385; font-size: 12px; }
.annotation-history-summary { margin-top: 7px; display: flex; flex-wrap: wrap; gap: 10px; color: #4b5666; font-size: 12px; }
.annotation-history-actions { display: flex; gap: 7px; }
.annotation-history-empty { padding: 40px 18px; text-align: center; color: #687385; }
@media (max-width: 820px) { .annotation-submit-status { display: none; } }
`;
document.head.appendChild(style);
const historyButton = button("提交记录", "secondary");
const submitButton = button("提交", "primary");
const status = document.createElement("span");
status.className = "annotation-submit-status";
toolbar.append(status, historyButton, submitButton);
const dialog = document.createElement("dialog");
dialog.className = "annotation-history-dialog";
dialog.innerHTML = `
<div class="annotation-history-head">
<h2>提交记录</h2>
<button class="annotation-history-close" aria-label="关闭">×</button>
</div>
<ol class="annotation-history-list"></ol>
`;
document.body.appendChild(dialog);
dialog.querySelector(".annotation-history-close").onclick = () =>
dialog.close();
dialog.addEventListener("click", (event) => {
if (event.target === dialog) dialog.close();
});
let history = [];
historyButton.onclick = async () => {
dialog.showModal();
await refreshHistory();
};
submitButton.onclick = async () => {
submitButton.disabled = true;
submitButton.textContent = "提交中";
try {
const response = await fetch(endpoint, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ annotations: bridge.read() }),
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || "提交失败");
history = [
summaryOf(payload.submission),
...history.filter((item) => item.id !== payload.submission.id),
];
setStatus(payload.submission);
setRevisionUrl(payload.submission.id);
bridge.notify?.(`已提交第 ${payload.submission.version}`);
} catch (error) {
bridge.notify?.(error instanceof Error ? error.message : "提交失败");
} finally {
submitButton.disabled = false;
submitButton.textContent = "提交";
}
};
async function refreshHistory() {
const list = dialog.querySelector(".annotation-history-list");
list.innerHTML = '<li class="annotation-history-empty">正在读取</li>';
try {
const payload = await fetchJson(endpoint);
history = payload.history || [];
renderHistory();
} catch (error) {
list.innerHTML = `<li class="annotation-history-empty">${escapeHtml(error instanceof Error ? error.message : "读取失败")}</li>`;
}
}
function renderHistory() {
const list = dialog.querySelector(".annotation-history-list");
if (!history.length) {
list.innerHTML =
'<li class="annotation-history-empty">还没有提交记录</li>';
return;
}
list.innerHTML = "";
for (const item of history) {
const li = document.createElement("li");
li.className = "annotation-history-item";
li.innerHTML = `
<div>
<div class="annotation-history-title">第 ${item.version} 版 · ${escapeHtml(item.submittedBy?.username || "未知用户")}</div>
<div class="annotation-history-meta">${formatTime(item.submittedAt)}</div>
<div class="annotation-history-summary">
<span>已标 ${item.summary?.completed || 0}/${item.summary?.total || 0}</span>
<span>G ${item.summary?.G || 0}</span>
<span>S ${item.summary?.S || 0}</span>
<span>B ${item.summary?.B || 0}</span>
</div>
</div>
<div class="annotation-history-actions">
<button class="btn secondary" data-action="copy" type="button">复制链接</button>
<button class="btn secondary" data-action="view" type="button">查看</button>
</div>
`;
li.querySelector('[data-action="copy"]').onclick = async () => {
const url = revisionUrl(item.id);
try {
await navigator.clipboard.writeText(url);
bridge.notify?.("版本链接已复制");
} catch {
window.prompt("复制版本链接", url);
}
};
li.querySelector('[data-action="view"]').onclick = async () => {
try {
const payload = await fetchJson(
`${endpoint}?revision=${encodeURIComponent(item.id)}`,
);
bridge.apply(payload.submission.annotations || []);
dialog.close();
setStatus(payload.submission, "正在查看");
setRevisionUrl(payload.submission.id);
bridge.notify?.(`已打开第 ${payload.submission.version}`);
} catch (error) {
bridge.notify?.(error instanceof Error ? error.message : "读取失败");
}
};
list.appendChild(li);
}
}
async function initialize() {
try {
const requestedRevision = new URLSearchParams(window.location.search).get(
"revision",
);
if (requestedRevision) {
const payload = await fetchJson(
`${endpoint}?revision=${encodeURIComponent(requestedRevision)}`,
);
bridge.apply(payload.submission.annotations || []);
setStatus(payload.submission, "正在查看");
return;
}
const payload = await fetchJson(endpoint);
history = payload.history || [];
if (payload.latest) {
bridge.applyMissing(payload.latest.annotations || []);
setStatus(payload.latest);
} else {
status.textContent = "尚未提交";
}
} catch {
status.textContent = "提交记录暂不可用";
}
}
function setStatus(submission, prefix = "最新") {
status.textContent = `${prefix}:第 ${submission.version} 版 · ${submission.submittedBy?.username || "未知用户"}`;
}
function revisionUrl(revisionId) {
const url = new URL(window.location.href);
url.searchParams.set("revision", revisionId);
return url.toString();
}
function setRevisionUrl(revisionId) {
window.history.replaceState(null, "", revisionUrl(revisionId));
}
function button(label, variant) {
const element = document.createElement("button");
element.type = "button";
element.className = `btn ${variant}`;
element.textContent = label;
return element;
}
async function fetchJson(url) {
const response = await fetch(url, { cache: "no-store" });
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || "请求失败");
return payload;
}
function summaryOf(submission) {
const { annotations: _annotations, ...summary } = submission;
return summary;
}
function formatTime(value) {
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false,
}).format(new Date(value));
}
function escapeHtml(value) {
return String(value ?? "").replace(
/[&<>"']/g,
(character) =>
({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[character],
);
}
void initialize();
})();