- {papers.map((p) => (
- -
- {p.title}
+ {papers.map((paper) => (
+
-
+ {paper.title}
- [{p.source}] {p.summary?.one_liner}
+ [{paper.source}] {paper.summary?.one_liner}
-
+
))}
diff --git a/web/app/(private)/paper/[id]/page.tsx b/web/app/(private)/paper/[id]/page.tsx
new file mode 100644
index 0000000..d852de0
--- /dev/null
+++ b/web/app/(private)/paper/[id]/page.tsx
@@ -0,0 +1,84 @@
+import { createHash } from "node:crypto";
+import { notFound } from "next/navigation";
+import AnnotatedReader from "@/components/AnnotatedReader";
+import FeedbackButtons from "@/components/FeedbackButtons";
+import { getAnnotations, getPaper } from "@/lib/data";
+import { renderMarkdown } from "@/lib/md";
+import { requireOwnerPage } from "@/lib/auth-boundary";
+
+export const dynamic = "force-dynamic";
+
+export default async function PaperPage({ params }: { params: Promise<{ id: string }> }) {
+ const owner = await requireOwnerPage();
+ const { id } = await params;
+ const [paper, annotations] = await Promise.all([
+ getPaper(owner, id),
+ getAnnotations(owner, id),
+ ]);
+ if (!paper) notFound();
+
+ const summary = paper.summary;
+ const signalEntries = Object.entries(paper.signals ?? {})
+ .filter(([key]) => ["upvotes", "stars", "category", "publisher"].includes(key))
+ .sort(([left], [right]) => left.localeCompare(right));
+ const contentVersion = createHash("sha256").update(JSON.stringify({
+ abstract: paper.abstract,
+ authors: paper.authors,
+ impact: summary?.impact_md ?? "",
+ oneLiner: summary?.one_liner ?? "",
+ rank: summary?.rank ?? null,
+ rating: paper.rating,
+ score: summary?.score ?? null,
+ signals: signalEntries,
+ source: paper.source,
+ summary: summary?.summary_md ?? "",
+ title: paper.title,
+ url: paper.url,
+ })).digest("hex");
+ const signals = signalEntries
+ .map(([key, value]) => `${key}:${value}`)
+ .join(" · ");
+
+ return (
+
+ {paper.source.toUpperCase()}
+ {paper.title}
+
+ {signals ? <>{signals} · > : null}
+ {summary?.score != null ? <>评分 {summary.score} · > : null}
+ {paper.authors?.length ? <>{paper.authors.slice(0, 6).join(", ")} · > : null}
+
原文链接 ↗
+
+
+
+
+ {summary?.one_liner ? (
+
+ {summary.one_liner}
+
+ ) : null}
+
+ 概要 / Summary
+
+
+ {summary?.impact_md ? (
+ <>
+ 对你的影响 / Impact
+
+ >
+ ) : null}
+
+ {summary?.score != null ? (
+
+ 归档评分 {summary.score}{summary.rank ? ` · 当日排名 #${summary.rank}` : ""}
+
+ ) : null}
+
+ );
+}
diff --git a/web/app/(private)/search/page.tsx b/web/app/(private)/search/page.tsx
new file mode 100644
index 0000000..004eba0
--- /dev/null
+++ b/web/app/(private)/search/page.tsx
@@ -0,0 +1,43 @@
+import Link from "next/link";
+import { searchPapers } from "@/lib/data";
+import { requireOwnerPage } from "@/lib/auth-boundary";
+
+export const dynamic = "force-dynamic";
+
+export default async function SearchPage({
+ searchParams,
+}: {
+ searchParams: Promise<{ q?: string }>;
+}) {
+ const owner = await requireOwnerPage();
+ const { q = "" } = await searchParams;
+ const query = q.trim();
+ const results = query ? await searchPapers(owner, query) : [];
+
+ return (
+
+ 检索 / Search
+ {query ? `「${query}」` : "跨库检索"}
+
+ {query
+ ? `在最近最多 500 篇有摘要项目中命中 ${results.length} 篇`
+ : "在左栏搜索框输入关键词,检索最近最多 500 篇有摘要项目。"}
+
+
+ {results.map((paper) => (
+
+
+ [{paper.source}]
+ {paper.summary?.score != null ? ` · 评分 ${paper.summary.score}` : ""}
+
+ {paper.title}
+ {paper.summary?.one_liner ? (
+ {paper.summary.one_liner}
+ ) : null}
+
+ ))}
+
+ {query && results.length === 0 ? 没有命中。换个关键词试试。
: null}
+
+ );
+}
diff --git a/web/app/api/annotations/route.ts b/web/app/api/annotations/route.ts
index 661182d..872ab4e 100644
--- a/web/app/api/annotations/route.ts
+++ b/web/app/api/annotations/route.ts
@@ -1,39 +1,81 @@
-import { getAnnotations, addAnnotation, deleteAnnotation, type AnnoType } from "@/lib/data";
+import { authorizeAPI } from "@/lib/auth-boundary";
+import { ownerWriteRateLimitResponse } from "@/lib/api-rate-limit";
+import { exactQuery, hasSameMutationOrigin, readBoundedJSON } from "@/lib/api-request";
+import { apiError, apiJSON } from "@/lib/api-response";
+import {
+ annotationDeleteQuerySchema,
+ annotationListQuerySchema,
+ annotationMutationSchema,
+} from "@/lib/api-schemas";
+import { addAnnotation, deleteAnnotation, getAnnotations } from "@/lib/data";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
-const TYPES: AnnoType[] = ["highlight", "note", "pen", "box"];
-const COLOR_RE = /^#[0-9a-fA-F]{6}$/;
+function databaseFailure(error: unknown): Response {
+ const code =
+ error !== null && typeof error === "object" && "code" in error
+ ? (error as { code?: unknown }).code
+ : undefined;
+ if (code === "DB_NOT_FOUND") return apiError(404, "NOT_FOUND");
+ if (code === "DB_OPERATION_FAILED") return apiError(503, "SERVICE_UNAVAILABLE");
+ return apiError(500, "INTERNAL_ERROR");
+}
-export async function GET(req: Request) {
- const id = new URL(req.url).searchParams.get("itemId");
- return Response.json({ annotations: id ? await getAnnotations(id) : [] });
+function requestFailure(reason: "INVALID_REQUEST" | "PAYLOAD_TOO_LARGE" | "UNSUPPORTED_MEDIA_TYPE") {
+ if (reason === "PAYLOAD_TOO_LARGE") return apiError(413, reason);
+ if (reason === "UNSUPPORTED_MEDIA_TYPE") return apiError(415, reason);
+ return apiError(400, reason);
}
-export async function POST(req: Request) {
- let body: { itemId?: string; type?: AnnoType; anchor?: unknown; color?: string; body?: string | null };
+export async function GET(request: Request) {
+ const authorization = await authorizeAPI();
+ if (!authorization.ok) return authorization.response;
+ const parsed = annotationListQuerySchema.safeParse(exactQuery(request, ["itemId"]));
+ if (!parsed.success) return apiError(400, "INVALID_REQUEST");
try {
- body = await req.json();
- } catch {
- return new Response("bad request", { status: 400 });
- }
- const { itemId, type, anchor, color } = body;
- if (!itemId || !type || !TYPES.includes(type) || anchor == null) {
- return new Response("bad request", { status: 400 });
+ return apiJSON({
+ annotations: await getAnnotations(authorization.owner, parsed.data.itemId),
+ });
+ } catch (error) {
+ return databaseFailure(error);
}
- if (color && !COLOR_RE.test(color)) return new Response("bad color", { status: 400 });
- // 防滥用:anchor 体积上限 + 便签正文上限
- if (JSON.stringify(anchor).length > 20_000) return new Response("anchor too large", { status: 413 });
- const note = typeof body.body === "string" ? body.body.slice(0, 1000) : null;
+}
+
+export async function POST(request: Request) {
+ const authorization = await authorizeAPI();
+ if (!authorization.ok) return authorization.response;
+ if (!hasSameMutationOrigin(request)) return apiError(403, "CROSS_ORIGIN_REQUEST");
+ const rateLimit = await ownerWriteRateLimitResponse(request, authorization.owner);
+ if (rateLimit) return rateLimit;
+ const body = await readBoundedJSON(request, { maxBytes: 32_768 });
+ if (!body.ok) return requestFailure(body.reason);
+ const parsed = annotationMutationSchema.safeParse(body.value);
+ if (!parsed.success) return apiError(400, "INVALID_REQUEST");
- const created = await addAnnotation(itemId, type, anchor, color ?? "#e0c060", note);
- return Response.json(created);
+ const { anchor, body: note, color, itemId, type } = parsed.data;
+ try {
+ return apiJSON(
+ await addAnnotation(authorization.owner, itemId, type, anchor, color, note),
+ 201,
+ );
+ } catch (error) {
+ return databaseFailure(error);
+ }
}
-export async function DELETE(req: Request) {
- const id = new URL(req.url).searchParams.get("id");
- if (!id) return new Response("bad request", { status: 400 });
- await deleteAnnotation(id);
- return Response.json({ ok: true });
+export async function DELETE(request: Request) {
+ const authorization = await authorizeAPI();
+ if (!authorization.ok) return authorization.response;
+ if (!hasSameMutationOrigin(request)) return apiError(403, "CROSS_ORIGIN_REQUEST");
+ const rateLimit = await ownerWriteRateLimitResponse(request, authorization.owner);
+ if (rateLimit) return rateLimit;
+ const parsed = annotationDeleteQuerySchema.safeParse(exactQuery(request, ["id"]));
+ if (!parsed.success) return apiError(400, "INVALID_REQUEST");
+ try {
+ await deleteAnnotation(authorization.owner, parsed.data.id);
+ return apiJSON({ ok: true });
+ } catch (error) {
+ return databaseFailure(error);
+ }
}
diff --git a/web/app/api/auth/login/route.ts b/web/app/api/auth/login/route.ts
new file mode 100644
index 0000000..0ebd48d
--- /dev/null
+++ b/web/app/api/auth/login/route.ts
@@ -0,0 +1,108 @@
+import { OwnerAuthError, ownerFromAuthResult } from "@/lib/auth";
+import {
+ createAuthHTTPContext,
+ fixedAuthResponse,
+ hasSameOrigin,
+ readLoginPassword,
+ withAuthDeadline,
+} from "@/lib/auth-http";
+import { consumeAPIRateLimit } from "@/lib/quota";
+
+type AuthFailure = Readonly<{ code?: unknown; status?: unknown }>;
+
+function loginFailure(error: AuthFailure): 401 | 429 | 503 {
+ if (error.status === 429) return 429;
+ if (
+ error.status === 400 ||
+ error.status === 401 ||
+ error.status === 403 ||
+ error.code === "invalid_credentials" ||
+ error.code === "email_not_confirmed" ||
+ error.code === "user_banned"
+ ) {
+ return 401;
+ }
+ return 503;
+}
+
+function failureResponse(
+ context: ReturnType
,
+ status: 401 | 429 | 503,
+) {
+ if (status === 401) return context.response("Authentication failed", 401);
+ if (status === 429) {
+ return context.response("Authentication temporarily unavailable", 429, {
+ "Retry-After": "60",
+ });
+ }
+ return context.response("Authentication unavailable", 503);
+}
+
+async function clearRejectedSession(
+ context: ReturnType,
+): Promise {
+ try {
+ await withAuthDeadline(context.client.auth.signOut({ scope: "local" }));
+ } catch {
+ // Local cookie removal below is authoritative for this browser.
+ } finally {
+ await context.clearLocalCookies();
+ }
+}
+
+export async function POST(request: import("next/server").NextRequest) {
+ if (!hasSameOrigin(request)) return fixedAuthResponse("Forbidden", 403);
+
+ try {
+ const decision = await consumeAPIRateLimit(request, "auth_login");
+ if (!decision.allowed) {
+ return fixedAuthResponse("Authentication temporarily unavailable", 429, {
+ "Retry-After": String(decision.retryAfter),
+ });
+ }
+ } catch {
+ return fixedAuthResponse("Authentication unavailable", 503);
+ }
+
+ let password: string | undefined;
+ try {
+ password = await readLoginPassword(request);
+ } catch {
+ return fixedAuthResponse("Invalid request", 400);
+ }
+ if (password === undefined) return fixedAuthResponse("Invalid request", 400);
+
+ let context: ReturnType;
+ try {
+ context = createAuthHTTPContext(request);
+ } catch {
+ return fixedAuthResponse("Authentication unavailable", 503);
+ }
+
+ try {
+ const signIn = await withAuthDeadline(
+ context.client.auth.signInWithPassword({
+ email: context.config.ownerEmail,
+ password,
+ }),
+ );
+ if (signIn.error) return failureResponse(context, loginFailure(signIn.error));
+
+ try {
+ ownerFromAuthResult(
+ await withAuthDeadline(context.client.auth.getUser()),
+ context.config.ownerEmail,
+ );
+ } catch (error) {
+ await clearRejectedSession(context);
+ if (error instanceof OwnerAuthError && error.status !== 503) {
+ return failureResponse(context, 401);
+ }
+ return failureResponse(context, 503);
+ }
+
+ return context.response(null, 204);
+ } catch {
+ return failureResponse(context, 503);
+ }
+}
diff --git a/web/app/api/auth/logout/route.ts b/web/app/api/auth/logout/route.ts
new file mode 100644
index 0000000..89ad9ba
--- /dev/null
+++ b/web/app/api/auth/logout/route.ts
@@ -0,0 +1,36 @@
+import {
+ createAuthHTTPContext,
+ fixedAuthResponse,
+ hasSameOrigin,
+ withAuthDeadline,
+} from "@/lib/auth-http";
+
+export async function POST(request: import("next/server").NextRequest) {
+ if (!hasSameOrigin(request)) return fixedAuthResponse("Forbidden", 403);
+
+ let context: ReturnType;
+ try {
+ context = createAuthHTTPContext(request);
+ } catch {
+ return fixedAuthResponse("Authentication unavailable", 503);
+ }
+
+ let remoteFailure = false;
+ try {
+ const result = await withAuthDeadline(
+ context.client.auth.signOut({ scope: "local" }),
+ );
+ remoteFailure = result.error !== null;
+ } catch {
+ remoteFailure = true;
+ }
+
+ try {
+ await context.clearLocalCookies();
+ } catch {
+ return context.response("Authentication unavailable", 503);
+ }
+
+ if (remoteFailure) return context.response("Authentication unavailable", 503);
+ return context.response(null, 303, { Location: "/login" });
+}
diff --git a/web/app/api/chat/route.ts b/web/app/api/chat/route.ts
index 3ba072c..ed0876e 100644
--- a/web/app/api/chat/route.ts
+++ b/web/app/api/chat/route.ts
@@ -1,34 +1,127 @@
+import { randomUUID } from "node:crypto";
import { getPaper, getChats, saveChat } from "@/lib/data";
import { deepseek, CHAT_MODEL } from "@/lib/llm";
+import { authorizeAPI } from "@/lib/auth-boundary";
+import { exactQuery, hasSameMutationOrigin, readBoundedJSON } from "@/lib/api-request";
+import {
+ apiError,
+ apiJSON,
+ apiLLMBudgetExhausted,
+ apiRateLimited,
+ hardenAPIResponse,
+} from "@/lib/api-response";
+import { chatHistoryQuerySchema, chatMutationSchema } from "@/lib/api-schemas";
+import {
+ consumeAPIRateLimit,
+ reserveLLMBudget,
+ settleLLMBudget,
+ type LLMBudgetReservation,
+} from "@/lib/quota";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
-const MAX_MESSAGE = 2000; // 防超长 prompt 抬高成本
+const CHAT_MAX_OUTPUT_TOKENS = 1_500;
+const MAX_LLM_RESERVATION_TOKENS = 65_536;
+const PROMPT_FRAMING_RESERVE = 4_096;
+const MESSAGE_FRAMING_RESERVE = 128;
+const CHAT_STREAM_DEADLINE_MS = 60_000;
+const MAX_CHAT_RESPONSE_BYTES = 256 * 1024;
+const encoder = new TextEncoder();
+
+type ChatPromptMessage = Readonly<{
+ content: string;
+ role: "assistant" | "system" | "user";
+}>;
+
+function chatReservationTokens(messages: readonly ChatPromptMessage[]): number | undefined {
+ let contentBytes = 0;
+ for (const message of messages) {
+ contentBytes += encoder.encode(message.content).byteLength;
+ if (contentBytes > MAX_LLM_RESERVATION_TOKENS) return undefined;
+ }
+ const upperBound =
+ contentBytes +
+ PROMPT_FRAMING_RESERVE +
+ messages.length * MESSAGE_FRAMING_RESERVE +
+ CHAT_MAX_OUTPUT_TOKENS;
+ return upperBound <= MAX_LLM_RESERVATION_TOKENS ? upperBound : undefined;
+}
+
+function databaseFailure(error: unknown): Response {
+ const code =
+ error !== null && typeof error === "object" && "code" in error
+ ? (error as { code?: unknown }).code
+ : undefined;
+ if (code === "DB_NOT_FOUND") return apiError(404, "NOT_FOUND");
+ if (code === "DB_OPERATION_FAILED") return apiError(503, "SERVICE_UNAVAILABLE");
+ return apiError(500, "INTERNAL_ERROR");
+}
+
+function requestFailure(reason: "INVALID_REQUEST" | "PAYLOAD_TOO_LARGE" | "UNSUPPORTED_MEDIA_TYPE") {
+ if (reason === "PAYLOAD_TOO_LARGE") return apiError(413, reason);
+ if (reason === "UNSUPPORTED_MEDIA_TYPE") return apiError(415, reason);
+ return apiError(400, reason);
+}
+
+async function nextWithAbort(
+ iterator: AsyncIterator,
+ signal: AbortSignal,
+): Promise> {
+ if (signal.aborted) throw new Error("chat provider deadline exceeded");
+ let onAbort: (() => void) | undefined;
+ const aborted = new Promise((_resolve, reject) => {
+ onAbort = () => reject(new Error("chat provider deadline exceeded"));
+ signal.addEventListener("abort", onAbort, { once: true });
+ });
+ try {
+ return await Promise.race([iterator.next(), aborted]);
+ } finally {
+ if (onAbort) signal.removeEventListener("abort", onAbort);
+ }
+}
// 历史对话
export async function GET(req: Request) {
- const id = new URL(req.url).searchParams.get("itemId");
- return Response.json({ chats: id ? await getChats(id) : [] });
+ const authorization = await authorizeAPI();
+ if (!authorization.ok) return authorization.response;
+ const parsed = chatHistoryQuerySchema.safeParse(exactQuery(req, ["itemId"]));
+ if (!parsed.success) return apiError(400, "INVALID_REQUEST");
+ try {
+ return apiJSON({ chats: await getChats(authorization.owner, parsed.data.itemId) });
+ } catch (error) {
+ return databaseFailure(error);
+ }
}
// 基于本篇论文的满上下文问答,流式返回纯文本;两端落库到 chats。
export async function POST(req: Request) {
- let body: { itemId?: string; message?: string };
+ const authorization = await authorizeAPI();
+ if (!authorization.ok) return authorization.response;
+ if (!hasSameMutationOrigin(req)) return apiError(403, "CROSS_ORIGIN_REQUEST");
try {
- body = (await req.json()) as { itemId?: string; message?: string };
+ const decision = await consumeAPIRateLimit(req, "web_chat", authorization.owner);
+ if (!decision.allowed) return apiRateLimited(decision.retryAfter);
} catch {
- return new Response("bad request", { status: 400 });
+ return apiError(503, "SERVICE_UNAVAILABLE");
}
- const { itemId, message } = body;
- if (!itemId || !message?.trim()) return new Response("bad request", { status: 400 });
- if (message.length > MAX_MESSAGE) return new Response("message too long", { status: 413 });
+ const body = await readBoundedJSON(req, { maxBytes: 8_192 });
+ if (!body.ok) return requestFailure(body.reason);
+ const parsed = chatMutationSchema.safeParse(body.value);
+ if (!parsed.success) return apiError(400, "INVALID_REQUEST");
+ const { itemId, message } = parsed.data;
- const paper = await getPaper(itemId);
- if (!paper) return new Response("not found", { status: 404 });
+ let paper;
+ let prior;
+ try {
+ paper = await getPaper(authorization.owner, itemId);
+ if (!paper) return apiError(404, "NOT_FOUND");
- // 历史从库里读,不信任客户端传入(防伪造/越权)。
- const prior = await getChats(itemId);
+ // 历史从库里读,不信任客户端传入(防伪造/越权)。
+ prior = await getChats(authorization.owner, itemId);
+ } catch (error) {
+ return databaseFailure(error);
+ }
const ctx = [
`标题:${paper.title}`,
@@ -45,39 +138,155 @@ export async function POST(req: Request) {
`你是嵌在「前沿论文情报台」阅读器里的助手。用户正在读下面这篇论文/项目,` +
`请主要基于这份资料并结合你的知识回答;资料未覆盖的内容要说明属推测。务实、简洁、中文为主、专有名词保留英文。\n\n=== 资料 ===\n${ctx}`;
- await saveChat(itemId, "user", message);
-
- const completion = await deepseek().chat.completions.create({
- model: CHAT_MODEL,
- stream: true,
- max_tokens: 1500,
- messages: [
- { role: "system", content: system },
- ...prior.slice(-8).map((h) => ({ role: h.role, content: h.content })),
- { role: "user", content: message },
- ],
- });
+ const messages: ChatPromptMessage[] = [
+ { role: "system", content: system },
+ ...prior.slice(-8).map((history) => ({
+ role: history.role,
+ content: history.content,
+ })),
+ { role: "user", content: message },
+ ];
+ const reservedTokens = chatReservationTokens(messages);
+ if (reservedTokens === undefined) return apiError(503, "SERVICE_UNAVAILABLE");
+
+ let reservation: LLMBudgetReservation;
+ try {
+ const budget = await reserveLLMBudget(
+ req,
+ authorization.owner,
+ randomUUID(),
+ reservedTokens,
+ );
+ if (!budget.allowed) return apiLLMBudgetExhausted(budget.retryAfter);
+ reservation = Object.freeze({
+ budgetDate: budget.budgetDate,
+ reservationId: budget.reservationId,
+ reservedTokens: budget.reservedTokens,
+ });
+ } catch {
+ return apiError(503, "SERVICE_UNAVAILABLE");
+ }
+
+ try {
+ await saveChat(authorization.owner, itemId, "user", message);
+ } catch (error) {
+ // No provider call has started. Close the reservation so a persistence
+ // outage cannot strand the full upper bound in the daily ledger.
+ try {
+ await settleLLMBudget(authorization.owner, reservation, null);
+ } catch {
+ // Keep the original database failure as the user-visible result.
+ }
+ return databaseFailure(error);
+ }
+
+ const providerController = new AbortController();
+ const abortProvider = () => providerController.abort();
+ req.signal.addEventListener("abort", abortProvider, { once: true });
+ const providerDeadline = setTimeout(abortProvider, CHAT_STREAM_DEADLINE_MS);
+ let completion;
+ try {
+ completion = await deepseek(authorization.owner).chat.completions.create({
+ model: CHAT_MODEL,
+ stream: true,
+ stream_options: { include_usage: true },
+ max_tokens: CHAT_MAX_OUTPUT_TOKENS,
+ messages,
+ }, { signal: providerController.signal });
+ } catch {
+ clearTimeout(providerDeadline);
+ req.signal.removeEventListener("abort", abortProvider);
+ try {
+ await settleLLMBudget(authorization.owner, reservation, null);
+ } catch {
+ // The reservation remains conservative when settlement is unavailable.
+ }
+ return apiError(502, "UPSTREAM_UNAVAILABLE");
+ }
- const encoder = new TextEncoder();
let full = "";
+ let outputBytes = 0;
+ let finishReason: string | undefined;
+ let settlementAttempted = false;
+ const settle = async (actualTokens: number | null): Promise => {
+ if (settlementAttempted) throw new Error("duplicate budget settlement");
+ settlementAttempted = true;
+ await settleLLMBudget(authorization.owner, reservation, actualTokens);
+ };
const stream = new ReadableStream({
async start(controller) {
+ let totalTokens: number | undefined;
+ const iterator = completion[Symbol.asyncIterator]();
try {
- for await (const part of completion) {
- const delta = part.choices[0]?.delta?.content ?? "";
+ for (;;) {
+ const next = await nextWithAbort(iterator, providerController.signal);
+ if (next.done) break;
+ const part = next.value;
+ const choice = part.choices[0];
+ if (choice && finishReason !== undefined) {
+ throw new Error("choice received after finish reason");
+ }
+ if (choice?.finish_reason) {
+ finishReason = choice.finish_reason;
+ }
+ const delta = choice?.delta?.content ?? "";
if (delta) {
+ outputBytes += encoder.encode(delta).byteLength;
+ if (outputBytes > MAX_CHAT_RESPONSE_BYTES) {
+ throw new Error("chat response too large");
+ }
full += delta;
controller.enqueue(encoder.encode(delta));
}
+ if (part.usage !== null && part.usage !== undefined) {
+ if (
+ totalTokens !== undefined ||
+ !Number.isSafeInteger(part.usage.total_tokens) ||
+ part.usage.total_tokens < 1 ||
+ part.usage.total_tokens > reservation.reservedTokens
+ ) {
+ throw new Error("invalid completion usage");
+ }
+ totalTokens = part.usage.total_tokens;
+ }
}
- } finally {
- if (full) await saveChat(itemId, "assistant", full);
+ if (finishReason !== "stop" || !full.trim() || totalTokens === undefined) {
+ throw new Error("incomplete completion");
+ }
+ await settle(totalTokens);
+ await saveChat(authorization.owner, itemId, "assistant", full);
controller.close();
+ } catch {
+ if (!settlementAttempted) {
+ try {
+ await settle(null);
+ } catch {
+ // The pending reservation continues to count against the hard cap.
+ }
+ }
+ try {
+ controller.error(new Error("chat stream failed"));
+ } catch {
+ // The client may already have cancelled the response stream.
+ }
+ } finally {
+ clearTimeout(providerDeadline);
+ req.signal.removeEventListener("abort", abortProvider);
+ try {
+ await iterator.return?.();
+ } catch {
+ // Best-effort cancellation of an uncooperative provider iterator.
+ }
}
},
+ cancel() {
+ providerController.abort();
+ },
});
- return new Response(stream, {
- headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-cache" },
- });
+ return hardenAPIResponse(
+ new Response(stream, {
+ headers: { "Content-Type": "text/plain; charset=utf-8" },
+ }),
+ );
}
diff --git a/web/app/api/export/[id]/route.ts b/web/app/api/export/[id]/route.ts
index d46b9b3..373f707 100644
--- a/web/app/api/export/[id]/route.ts
+++ b/web/app/api/export/[id]/route.ts
@@ -1,5 +1,9 @@
import { getPaper, getAnnotations, type Annotation } from "@/lib/data";
import { Document, Packer, Paragraph, HeadingLevel, TextRun } from "docx";
+import { authorizeAPI } from "@/lib/auth-boundary";
+import { exactQuery } from "@/lib/api-request";
+import { apiError, hardenAPIResponse } from "@/lib/api-response";
+import { exportPathSchema, exportQuerySchema } from "@/lib/api-schemas";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
@@ -47,28 +51,60 @@ async function buildDocx(md: string): Promise {
return Packer.toBuffer(doc);
}
+function databaseFailure(error: unknown): Response {
+ const code =
+ error !== null && typeof error === "object" && "code" in error
+ ? (error as { code?: unknown }).code
+ : undefined;
+ if (code === "DB_NOT_FOUND") return apiError(404, "NOT_FOUND");
+ if (code === "DB_OPERATION_FAILED") return apiError(503, "SERVICE_UNAVAILABLE");
+ return apiError(500, "INTERNAL_ERROR");
+}
+
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
- const { id } = await params;
- const format = new URL(req.url).searchParams.get("format") ?? "md";
- const paper = await getPaper(id);
- if (!paper) return new Response("not found", { status: 404 });
- const annos = await getAnnotations(id);
- const md = buildMarkdown(paper, annos);
+ const authorization = await authorizeAPI();
+ if (!authorization.ok) return authorization.response;
+ const path = exportPathSchema.safeParse(await params);
+ const query = exportQuerySchema.safeParse(exactQuery(req, ["format"]));
+ if (!path.success || !query.success) return apiError(400, "INVALID_REQUEST");
+ const { id } = path.data;
+ const { format } = query.data;
+
+ let paper;
+ let annos;
+ try {
+ paper = await getPaper(authorization.owner, id);
+ if (!paper) return apiError(404, "NOT_FOUND");
+ annos = await getAnnotations(authorization.owner, id);
+ } catch (error) {
+ return databaseFailure(error);
+ }
const safe = (paper.title || "paper").replace(/[^\w一-龥]+/g, "_").slice(0, 40);
const fn = (ext: string) => `attachment; filename*=UTF-8''${encodeURIComponent(safe)}.${ext}`;
- if (format === "docx") {
- const buf = await buildDocx(md);
- return new Response(new Uint8Array(buf), {
- headers: {
- "Content-Type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
- "Content-Disposition": fn("docx"),
- },
- });
+ try {
+ const md = buildMarkdown(paper, annos);
+ if (format === "docx") {
+ const buf = await buildDocx(md);
+ return hardenAPIResponse(
+ new Response(new Uint8Array(buf), {
+ headers: {
+ "Content-Type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
+ "Content-Disposition": fn("docx"),
+ },
+ }),
+ );
+ }
+ return hardenAPIResponse(
+ new Response(md, {
+ headers: {
+ "Content-Type": "text/markdown; charset=utf-8",
+ "Content-Disposition": fn("md"),
+ },
+ }),
+ );
+ } catch {
+ return apiError(500, "INTERNAL_ERROR");
}
- // 默认 markdown
- return new Response(md, {
- headers: { "Content-Type": "text/markdown; charset=utf-8", "Content-Disposition": fn("md") },
- });
}
diff --git a/web/app/api/feedback/redeem/route.ts b/web/app/api/feedback/redeem/route.ts
new file mode 100644
index 0000000..adea228
--- /dev/null
+++ b/web/app/api/feedback/redeem/route.ts
@@ -0,0 +1,57 @@
+import { authorizeAPI } from "@/lib/auth-boundary";
+import { ownerWriteRateLimitResponse } from "@/lib/api-rate-limit";
+import { hasSameMutationOrigin, readBoundedJSON } from "@/lib/api-request";
+import { apiError, apiJSON } from "@/lib/api-response";
+import { feedbackRedemptionSchema } from "@/lib/api-schemas";
+import { redeemFeedbackToken } from "@/lib/data";
+import { verifyFeedbackToken } from "@/lib/sign";
+
+export const runtime = "nodejs";
+export const dynamic = "force-dynamic";
+
+function requestFailure(reason: "INVALID_REQUEST" | "PAYLOAD_TOO_LARGE" | "UNSUPPORTED_MEDIA_TYPE") {
+ if (reason === "PAYLOAD_TOO_LARGE") return apiError(413, reason);
+ if (reason === "UNSUPPORTED_MEDIA_TYPE") return apiError(415, reason);
+ return apiError(400, reason);
+}
+
+function databaseFailure(error: unknown): Response {
+ const code =
+ error !== null && typeof error === "object" && "code" in error
+ ? (error as { code?: unknown }).code
+ : undefined;
+ if (code === "DB_OPERATION_FAILED") return apiError(503, "SERVICE_UNAVAILABLE");
+ return apiError(500, "INTERNAL_ERROR");
+}
+
+export async function POST(request: Request): Promise {
+ const authorization = await authorizeAPI();
+ if (!authorization.ok) return authorization.response;
+ if (!hasSameMutationOrigin(request)) return apiError(403, "CROSS_ORIGIN_REQUEST");
+ const rateLimit = await ownerWriteRateLimitResponse(request, authorization.owner);
+ if (rateLimit) return rateLimit;
+
+ const body = await readBoundedJSON(request, { maxBytes: 4_096 });
+ if (!body.ok) return requestFailure(body.reason);
+ const parsed = feedbackRedemptionSchema.safeParse(body.value);
+ if (!parsed.success) return apiError(400, "INVALID_REQUEST");
+
+ let claims;
+ try {
+ claims = verifyFeedbackToken(parsed.data.token);
+ } catch {
+ return apiError(503, "SERVICE_UNAVAILABLE");
+ }
+ if (!claims) return apiError(410, "TOKEN_INVALID_OR_EXPIRED");
+
+ try {
+ const result = await redeemFeedbackToken(authorization.owner, claims);
+ if (result.ok) return apiJSON({ ok: true, rating: result.rating });
+ if (result.reason === "already_redeemed") {
+ return apiError(409, "FEEDBACK_ALREADY_REDEEMED");
+ }
+ return apiError(410, "TOKEN_INVALID_OR_EXPIRED");
+ } catch (error) {
+ return databaseFailure(error);
+ }
+}
diff --git a/web/app/api/feedback/route.ts b/web/app/api/feedback/route.ts
index 07fc1b3..fd25807 100644
--- a/web/app/api/feedback/route.ts
+++ b/web/app/api/feedback/route.ts
@@ -1,52 +1,53 @@
import { saveFeedback } from "@/lib/data";
-import { signFeedback, verifyFeedback } from "@/lib/sign";
+import { authorizeAPI } from "@/lib/auth-boundary";
+import { ownerWriteRateLimitResponse } from "@/lib/api-rate-limit";
+import { hasSameMutationOrigin, readBoundedJSON } from "@/lib/api-request";
+import { apiError, apiJSON } from "@/lib/api-response";
+import { feedbackMutationSchema } from "@/lib/api-schemas";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
-function page(body: string): Response {
- const html =
- `` +
- `前沿论文情报台` +
- `` +
- `${body}
`;
- return new Response(html, { headers: { "Content-Type": "text/html; charset=utf-8" } });
+function databaseFailure(error: unknown): Response {
+ const code =
+ error !== null && typeof error === "object" && "code" in error
+ ? (error as { code?: unknown }).code
+ : undefined;
+ if (code === "DB_NOT_FOUND") return apiError(404, "NOT_FOUND");
+ if (code === "DB_OPERATION_FAILED") return apiError(503, "SERVICE_UNAVAILABLE");
+ return apiError(500, "INTERNAL_ERROR");
}
-// 微信一键反馈:签名校验,免登录。
-export async function GET(req: Request) {
- const u = new URL(req.url);
- const i = u.searchParams.get("i");
- const r = u.searchParams.get("r");
- const t = u.searchParams.get("t");
- if (!i || (r !== "up" && r !== "down") || !t) return page("链接无效。
");
- if (!verifyFeedback(i, r, t)) return new Response("invalid token", { status: 403 });
- try {
- await saveFeedback(i, r);
- } catch {
- return page("记录失败,请稍后重试。
");
- }
- const other = r === "up" ? "down" : "up";
- const switchUrl = `/api/feedback?i=${i}&r=${other}&t=${signFeedback(i, other)}`;
- return page(
- `已记录 ${r === "up" ? "👍 有用" : "👎 不相关"}
` +
- `明天的 Top5 会更准。
` +
- `点错了?改为 ${other === "up" ? "👍 有用" : "👎 不相关"}
`,
- );
+function requestFailure(reason: "INVALID_REQUEST" | "PAYLOAD_TOO_LARGE" | "UNSUPPORTED_MEDIA_TYPE") {
+ if (reason === "PAYLOAD_TOO_LARGE") return apiError(413, reason);
+ if (reason === "UNSUPPORTED_MEDIA_TYPE") return apiError(415, reason);
+ return apiError(400, reason);
+}
+
+// Deprecated click-to-write links are inert; new links open /feedback first.
+export async function GET(request: Request) {
+ void request;
+ const authorization = await authorizeAPI();
+ if (!authorization.ok) return authorization.response;
+ return apiError(410, "TOKEN_INVALID_OR_EXPIRED");
}
-// 网页内打分(带理由)。走正常登录;demo 只读会被 middleware 拦成 403。
+// 网页内打分(带理由):仅接受已验证 owner 会话。
export async function POST(req: Request) {
- let body: { itemId?: string; rating?: string; note?: string };
+ const authorization = await authorizeAPI();
+ if (!authorization.ok) return authorization.response;
+ if (!hasSameMutationOrigin(req)) return apiError(403, "CROSS_ORIGIN_REQUEST");
+ const rateLimit = await ownerWriteRateLimitResponse(req, authorization.owner);
+ if (rateLimit) return rateLimit;
+ const body = await readBoundedJSON(req, { maxBytes: 4_096 });
+ if (!body.ok) return requestFailure(body.reason);
+ const parsed = feedbackMutationSchema.safeParse(body.value);
+ if (!parsed.success) return apiError(400, "INVALID_REQUEST");
+ const { itemId, rating, note } = parsed.data;
try {
- body = await req.json();
- } catch {
- return new Response("bad request", { status: 400 });
+ await saveFeedback(authorization.owner, itemId, rating, note);
+ } catch (error) {
+ return databaseFailure(error);
}
- const { itemId, rating, note } = body;
- if (!itemId || (rating !== "up" && rating !== "down")) return new Response("bad request", { status: 400 });
- await saveFeedback(itemId, rating, note ?? null);
- return Response.json({ ok: true });
+ return apiJSON({ ok: true });
}
diff --git a/web/app/feedback/page.tsx b/web/app/feedback/page.tsx
new file mode 100644
index 0000000..536691c
--- /dev/null
+++ b/web/app/feedback/page.tsx
@@ -0,0 +1,65 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import FeedbackConfirmation from "@/components/FeedbackConfirmation";
+import { requireOwnerPage } from "@/lib/auth-boundary";
+import { verifyFeedbackToken } from "@/lib/sign";
+
+export const dynamic = "force-dynamic";
+export const revalidate = 0;
+export const metadata: Metadata = {
+ referrer: "no-referrer",
+ robots: { follow: false, index: false },
+ title: "确认反馈 · 前沿论文情报台",
+};
+
+type SearchParams = Record;
+
+function message(text: string) {
+ return (
+
+
+ 私人反馈 / Private Feedback
+ 确认反馈
+ {text}
+ 返回首页
+
+
+ );
+}
+
+export default async function FeedbackPage({
+ searchParams,
+}: {
+ searchParams: Promise;
+}) {
+ await requireOwnerPage();
+ const params = await searchParams;
+ const token = params.token;
+ if (
+ Object.keys(params).length !== 1 ||
+ typeof token !== "string" ||
+ token.length === 0 ||
+ new TextEncoder().encode(token).byteLength > 256
+ ) {
+ return message("链接无效或已过期。");
+ }
+
+ let claims;
+ try {
+ claims = verifyFeedbackToken(token);
+ } catch {
+ return message("反馈服务暂不可用,请稍后重试。");
+ }
+ if (!claims) return message("链接无效或已过期。");
+
+ return (
+
+
+ 私人反馈 / Private Feedback
+ 确认反馈
+ 日报日期:{claims.digestDate}
+
+
+
+ );
+}
diff --git a/web/app/global-error.tsx b/web/app/global-error.tsx
index 69a7319..af461af 100644
--- a/web/app/global-error.tsx
+++ b/web/app/global-error.tsx
@@ -1,12 +1,12 @@
"use client";
// 自定义全局错误边界:规避 Next 16 Turbopack 找不到内置 global-error 模块的 bundler bug。
-export default function GlobalError({ error, reset }: { error: Error; reset: () => void }) {
+export default function GlobalError({ reset }: { error: Error; reset: () => void }) {
return (
电讯中断 / Something broke
- {error?.message ?? "未知错误"}
+ 服务暂时不可用,请稍后重试。
diff --git a/web/app/globals.css b/web/app/globals.css
index 46e0f5a..50b431e 100644
--- a/web/app/globals.css
+++ b/web/app/globals.css
@@ -7,8 +7,8 @@
--ink-soft: #6d5c45;
--wax: #8a3324; /* 火漆红 / sealing-wax red */
--thread: #5b4a36;
- --font-typewriter: "Special Elite", "Courier New", monospace;
- --font-serif-sc: "Songti SC", "STSong", "Noto Serif SC", Georgia, serif;
+ --font-typewriter: "Courier New", "Noto Sans Mono CJK SC", monospace;
+ --font-serif-sc: "Songti SC", "STSong", "SimSun", Georgia, serif;
}
* { box-sizing: border-box; }
@@ -129,6 +129,17 @@ body {
.sheet .meta { font-size: 12px; color: var(--ink-soft); border-bottom: 1px dashed var(--thread); padding-bottom: 12px; margin-bottom: 20px; }
.sheet .meta a { color: var(--wax); word-break: break-all; }
+.login-shell { min-height: 100vh; display: grid; place-items: center; padding: 32px; }
+.login-sheet { width: min(520px, 100%); }
+.login-form { display: grid; gap: 12px; }
+.login-form label { font-family: var(--font-typewriter); font-size: 13px; }
+.login-form input { padding: 11px 12px; border: 1px solid var(--thread); background: var(--paper); color: var(--ink); font: inherit; }
+.login-form button { justify-self: start; padding: 8px 14px; border: 1px solid var(--thread); background: var(--wax); color: var(--paper); font: inherit; cursor: pointer; }
+.login-form button:disabled { cursor: wait; opacity: .65; }
+.login-form [role="status"] { margin: 0; color: var(--wax); font-size: 13px; }
+.logout-form { margin: 0 0 14px; }
+.logout-form button { border: 0; padding: 0; background: transparent; color: var(--ink-soft); font: 11px var(--font-typewriter); cursor: pointer; text-decoration: underline; }
+
.section-label {
font-family: var(--font-typewriter); font-size: 13px; letter-spacing: 3px;
text-transform: uppercase; color: var(--wax); margin: 26px 0 8px;
@@ -211,22 +222,6 @@ body {
body { background: #fff; }
}
-/* 只读 demo 角标 */
-.demo-badge {
- position: fixed; top: 10px; left: 50%; transform: translateX(-50%);
- z-index: 100; text-decoration: none;
- background: var(--wax); color: var(--paper);
- font-family: var(--font-typewriter); font-size: 12px; letter-spacing: 1px;
- padding: 5px 14px; border-radius: 2px; box-shadow: 0 2px 8px rgba(0,0,0,0.3);
-}
-.demo-badge:hover { filter: brightness(1.12); }
-
-/* demo 角标:改为左栏内嵌,不再 fixed 浮在牛皮纸上方 */
-.demo-badge {
- position: static; transform: none; display: block; text-align: center;
- margin: 0 0 12px; padding: 6px 8px; box-shadow: none; font-size: 11px;
-}
-
/* Top5 反馈打分 */
.fb { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 6px; font-size: 12px; }
.fb-btn { background: var(--paper); border: 1px solid var(--thread); cursor: pointer; padding: 2px 9px; border-radius: 2px; font-size: 14px; line-height: 1.2; }
diff --git a/web/app/layout.tsx b/web/app/layout.tsx
index de1a47a..655e3ac 100644
--- a/web/app/layout.tsx
+++ b/web/app/layout.tsx
@@ -1,47 +1,15 @@
import "./globals.css";
import type { ReactNode } from "react";
-import PaperList from "@/components/PaperList";
-import ChatPanel from "@/components/ChatPanel";
export const metadata = {
title: "前沿论文情报台",
description: "每日 AI 前沿 Top5 · 复古电讯版",
};
-const DEMO = process.env.NEXT_PUBLIC_DEMO_MODE === "1";
-
export default function RootLayout({ children }: { children: ReactNode }) {
return (
-
-
-
-
-
-
-
-
+ {children}
);
}
diff --git a/web/app/login/page.tsx b/web/app/login/page.tsx
new file mode 100644
index 0000000..e6c0ac6
--- /dev/null
+++ b/web/app/login/page.tsx
@@ -0,0 +1,27 @@
+import LoginForm from "@/components/LoginForm";
+import { normalizeReturnTo } from "@/lib/return-to";
+import type { Metadata } from "next";
+
+export const dynamic = "force-dynamic";
+export const metadata: Metadata = {
+ referrer: "no-referrer",
+ robots: "noindex, nofollow, noarchive",
+};
+
+export default async function LoginPage({
+ searchParams,
+}: {
+ searchParams: Promise>;
+}) {
+ const returnTo = normalizeReturnTo((await searchParams).returnTo);
+ return (
+
+
+ 私人电讯 / Private Dispatch
+ 身份验证
+ 请输入预先配置的单一 owner 账户口令。
+
+
+
+ );
+}
diff --git a/web/app/paper/[id]/page.tsx b/web/app/paper/[id]/page.tsx
deleted file mode 100644
index 22c1d2a..0000000
--- a/web/app/paper/[id]/page.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-import { notFound } from "next/navigation";
-import { getPaper, getAnnotations } from "@/lib/data";
-import { renderMarkdown } from "@/lib/md";
-import AnnotatedReader from "@/components/AnnotatedReader";
-import FeedbackButtons from "@/components/FeedbackButtons";
-
-export const dynamic = "force-dynamic";
-
-export default async function PaperPage({ params }: { params: Promise<{ id: string }> }) {
- const { id } = await params;
- const [paper, annos] = await Promise.all([getPaper(id), getAnnotations(id)]);
- if (!paper) notFound();
-
- const s = paper.summary;
- const sig = Object.entries(paper.signals ?? {})
- .filter(([k]) => ["upvotes", "stars", "category", "publisher"].includes(k))
- .map(([k, v]) => `${k}:${v}`)
- .join(" · ");
-
- return (
-
- {paper.source.toUpperCase()}
- {paper.title}
-
- {sig ? <>{sig} · > : null}
- {s?.score != null ? <>评分 {s.score} · > : null}
- {paper.authors?.length ? <>{paper.authors.slice(0, 6).join(", ")} · > : null}
-
原文链接 ↗
-
-
-
-
- {s?.one_liner ? (
-
- {s.one_liner}
-
- ) : null}
-
- 概要 / Summary
-
-
- {s?.impact_md ? (
- <>
- 对你的影响 / Impact
-
- >
- ) : null}
-
- {s?.score != null ? (
- 归档评分 {s.score}{s.rank ? ` · 当日排名 #${s.rank}` : ""}
- ) : null}
-
- );
-}
diff --git a/web/app/search/page.tsx b/web/app/search/page.tsx
deleted file mode 100644
index fbcaad1..0000000
--- a/web/app/search/page.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import Link from "next/link";
-import { searchPapers } from "@/lib/data";
-
-export const dynamic = "force-dynamic";
-
-export default async function SearchPage({ searchParams }: { searchParams: Promise<{ q?: string }> }) {
- const { q = "" } = await searchParams;
- const query = q.trim();
- const results = query ? await searchPapers(query) : [];
-
- return (
-
- 检索 / Search
- {query ? `「${query}」` : "跨库检索"}
-
- {query ? `在你收录的论文里命中 ${results.length} 篇` : "在左栏搜索框输入关键词,跨你收藏的全部论文检索。"}
-
-
- {results.map((p) => (
-
-
- [{p.source}]{p.summary?.score != null ? ` · 评分 ${p.summary.score}` : ""}
-
- {p.title}
- {p.summary?.one_liner ? {p.summary.one_liner}
: null}
-
- ))}
-
- {query && results.length === 0 ? 没有命中。换个关键词试试。
: null}
-
- );
-}
diff --git a/web/components/AnnotatedReader.tsx b/web/components/AnnotatedReader.tsx
index faeb6ca..24176bd 100644
--- a/web/components/AnnotatedReader.tsx
+++ b/web/components/AnnotatedReader.tsx
@@ -1,12 +1,29 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
+import { privateFetch } from "@/lib/private-fetch";
+import {
+ isAnnotationAnchor,
+ isVersionedAnchor,
+ locateAnchoredQuote,
+ toPixelPoint,
+ toPixelRect,
+ type AnnotationType,
+ type Point,
+ type Rect,
+} from "@/lib/annotation-anchor";
-type AnnoType = "highlight" | "note" | "pen" | "box";
-type Anno = { id: string; type: AnnoType; anchor: any; color: string; body: string | null };
+interface Anno {
+ anchor: unknown;
+ body: string | null;
+ color: string;
+ id: string;
+ type: AnnotationType;
+}
type Tool = "select" | "highlight" | "note" | "pen" | "box" | "erase";
const COLORS = ["#e0c060", "#8a3324", "#5a7d5a", "#4a6a8a"];
+const MAX_PEN_POINTS = 512;
const TOOLS: { id: Tool; label: string }[] = [
{ id: "select", label: "选" },
{ id: "highlight", label: "高亮" },
@@ -15,24 +32,116 @@ const TOOLS: { id: Tool; label: string }[] = [
{ id: "box", label: "框选" },
{ id: "erase", label: "擦" },
];
-const DEMO = process.env.NEXT_PUBLIC_DEMO_MODE === "1";
+function isRecord(value: unknown): value is Record {
+ return value !== null && typeof value === "object" && !Array.isArray(value);
+}
+
+function isAnnotation(value: unknown): value is Anno {
+ if (!isRecord(value) || !isRecord(value.anchor)) return false;
+ if (
+ typeof value.id !== "string" ||
+ !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value.id) ||
+ typeof value.color !== "string" ||
+ !/^#[0-9a-f]{6}$/i.test(value.color) ||
+ (value.body !== null && typeof value.body !== "string")
+ ) {
+ return false;
+ }
+ return (
+ value.type === "note" ||
+ value.type === "box" ||
+ value.type === "highlight" ||
+ value.type === "pen"
+ ) &&
+ isAnnotationAnchor(value.type, value.anchor);
+}
+
+function anchorRecord(value: unknown): Record {
+ return isRecord(value) ? value : {};
+}
+
+function finiteNumber(value: unknown): number {
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
+}
+
+function textRange(root: HTMLElement, start: number, end: number): Range | null {
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
+ let offset = 0;
+ let startNode: Text | undefined;
+ let endNode: Text | undefined;
+ let startOffset = 0;
+ let endOffset = 0;
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
+ const text = node.textContent ?? "";
+ const nextOffset = offset + text.length;
+ if (!startNode && start >= offset && start <= nextOffset) {
+ startNode = node as Text;
+ startOffset = start - offset;
+ }
+ if (end >= offset && end <= nextOffset) {
+ endNode = node as Text;
+ endOffset = end - offset;
+ break;
+ }
+ offset = nextOffset;
+ }
+ if (!startNode || !endNode) return null;
+ const range = document.createRange();
+ range.setStart(startNode, startOffset);
+ range.setEnd(endNode, endOffset);
+ return range;
+}
export default function AnnotatedReader({
paperId,
initial,
children,
+ contentVersion = "legacy",
}: {
paperId: string;
initial: Anno[];
children: ReactNode;
+ contentVersion?: string;
}) {
const sheetRef = useRef(null);
+ const contentRef = useRef(null);
const [annos, setAnnos] = useState(initial);
const [tool, setTool] = useState("select");
const [color, setColor] = useState(COLORS[0]);
const [dims, setDims] = useState({ w: 0, h: 0 });
const [draft, setDraft] = useState(null);
- const drawing = useRef<{ type: "pen" | "box"; pts: number[][]; sx: number; sy: number } | null>(null);
+ const [reanchoredHighlights, setReanchoredHighlights] = useState<
+ ReadonlyMap