Jelajahi Sumber

Initial LAN reader chat deployment-ready app

zhaozhi 2 minggu lalu
melakukan
1ad96ccc7a

+ 9 - 0
.gitignore

@@ -0,0 +1,9 @@
+.next
+node_modules
+out
+uploads
+storage
+*.log
+artifacts
+.edge-reader-test
+storage-test.*

+ 84 - 0
README.md

@@ -0,0 +1,84 @@
+# 局域网书房
+
+一个部署在局域网环境里的内部网站,当前包含三块核心能力:
+
+1. 局域网聊天室
+2. 小说书架与阅读器
+3. OpenClaw Agent 观察室
+
+## 当前技术栈
+
+- Next.js 15
+- React 19
+- TypeScript
+- SQLite
+- 本地文件上传目录
+
+## 当前功能
+
+### 1. 局域网聊天室
+
+- 支持文字消息
+- 支持图片和文件上传
+- 支持聊天记录持久化
+- 支持清理服务器历史聊天记录
+- 支持局域网内设备在线状态显示
+
+### 2. 小说书架与阅读器
+
+- 书架页
+- 阅读页
+- 字号、主题、版心切换
+- 桌面端与移动端阅读适配
+
+### 3. Agent 观察室
+
+- 展示 OpenClaw agent 当前任务
+- 展示心跳、队列、今日完成数
+- 支持从本地 JSON 数据源读取真实状态
+- 没有真实数据时回退到演示数据
+
+## 本地启动
+
+安装依赖:
+
+```bash
+npm install
+```
+
+开发模式:
+
+```bash
+npm run dev
+```
+
+生产构建:
+
+```bash
+npm run build
+```
+
+生产启动:
+
+```bash
+npm run start -- --hostname 0.0.0.0 --port 3000
+```
+
+## 重要目录
+
+```text
+storage/chat.sqlite
+storage/chat-uploads/
+storage/agents/openclaw-agents.json
+```
+
+## 重要文档
+
+- `docs/macbook-deployment.md`
+- `docs/openclaw-ops-runbook.md`
+- `docs/openclaw-agent-feed.md`
+- `docs/openclaw-handoff.md`
+
+## 说明
+
+这个项目当前面向局域网部署场景,推荐部署到一台 MacBook 上,并由 OpenClaw 的 ops 运维专员负责日常维护。

+ 117 - 0
app/agents/[agentId]/page.tsx

@@ -0,0 +1,117 @@
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { AgentAvatar } from "@/components/agents/agent-avatar";
+import { loadAgentFeed } from "@/lib/agent-monitor";
+
+const statusClassMap = {
+  working: "status-pill status-pill--working",
+  idle: "status-pill status-pill--idle",
+  warning: "status-pill status-pill--warning",
+  offline: "status-pill status-pill--offline"
+} as const;
+
+type AgentDetailPageProps = {
+  params: Promise<{
+    agentId: string;
+  }>;
+};
+
+export default async function AgentDetailPage({ params }: AgentDetailPageProps) {
+  const { agentId } = await params;
+  const feed = await loadAgentFeed("all");
+  const agent = feed.agents.find((item) => item.id === agentId);
+
+  if (!agent) {
+    notFound();
+  }
+
+  return (
+    <main className="page-shell">
+      <section className="page-title">
+        <h1>{agent.name}</h1>
+        <p>这里保留单个 agent 的详细监控页,重点看当前任务、最近输出、心跳、队列和异常。</p>
+      </section>
+
+      <section className="agents-shell">
+        <div className="agent-detail-hero">
+          <div className="agent-detail-hero__main">
+            <div className="agent-card__header">
+              <AgentAvatar kind={agent.avatarKind} label={agent.name} large />
+              <div>
+                <div className="agent-card__name">{agent.name}</div>
+                <div className="agent-card__role">{agent.role}</div>
+                <div className="chip-row" style={{ marginTop: 14 }}>
+                  <span className={statusClassMap[agent.status]}>{agent.statusLabel}</span>
+                  <span className="chip">主机 {agent.host}</span>
+                  <span className="chip">Owner {agent.owner}</span>
+                </div>
+              </div>
+            </div>
+          </div>
+          <div className="agent-detail-hero__actions">
+            <Link className="button button--secondary" href="/agents">
+              返回总览
+            </Link>
+          </div>
+        </div>
+
+        <div className="agent-detail-grid">
+          <div className="reader-panel" style={{ padding: 20 }}>
+            <div className="agent-detail-section__title">运行状态</div>
+            <div className="agent-detail-list">
+              <div className="agent-row">
+                <div className="agent-row__label">当前状态</div>
+                <div className="agent-row__value agent-row__value--strong">{agent.statusLabel}</div>
+              </div>
+              <div className="agent-row">
+                <div className="agent-row__label">最近心跳</div>
+                <div className="agent-row__value">{agent.lastHeartbeat}</div>
+              </div>
+              <div className="agent-row">
+                <div className="agent-row__label">运行时长</div>
+                <div className="agent-row__value">{agent.uptime}</div>
+              </div>
+              <div className="agent-row">
+                <div className="agent-row__label">最近刷新</div>
+                <div className="agent-row__value">{agent.updatedAt}</div>
+              </div>
+            </div>
+          </div>
+
+          <div className="reader-panel" style={{ padding: 20 }}>
+            <div className="agent-detail-section__title">任务指标</div>
+            <div className="agent-quick-grid">
+              <div className="agent-mini-stat">
+                <span>任务ID</span>
+                <strong>{agent.taskId}</strong>
+              </div>
+              <div className="agent-mini-stat">
+                <span>阶段</span>
+                <strong>{agent.taskStage}</strong>
+              </div>
+              <div className="agent-mini-stat">
+                <span>队列深度</span>
+                <strong>{agent.queueDepth}</strong>
+              </div>
+              <div className="agent-mini-stat">
+                <span>今日完成</span>
+                <strong>{agent.todayCompleted}</strong>
+              </div>
+            </div>
+          </div>
+
+          <div className="reader-panel" style={{ padding: 20 }}>
+            <div className="agent-detail-section__title">当前任务</div>
+            <div className="agent-detail-highlight">{agent.currentTask}</div>
+          </div>
+
+          <div className="reader-panel" style={{ padding: 20 }}>
+            <div className="agent-detail-section__title">最近输出</div>
+            <div className="agent-detail-paragraph">{agent.lastOutput}</div>
+            {agent.lastError ? <div className="agent-detail-note">异常标记: {agent.lastError}</div> : null}
+          </div>
+        </div>
+      </section>
+    </main>
+  );
+}

+ 29 - 0
app/agents/page.tsx

@@ -0,0 +1,29 @@
+import { AgentsDashboard } from "@/components/agents/agents-dashboard";
+import { loadAgentFeed } from "@/lib/agent-monitor";
+import { AgentStatus } from "@/types/agent";
+
+type AgentsPageProps = {
+  searchParams?: Promise<{
+    status?: string;
+  }>;
+};
+
+function isAgentStatus(value: string | undefined): value is AgentStatus {
+  return value === "working" || value === "idle" || value === "warning" || value === "offline";
+}
+
+export default async function AgentsPage({ searchParams }: AgentsPageProps) {
+  const resolvedSearchParams = searchParams ? await searchParams : undefined;
+  const activeStatus = isAgentStatus(resolvedSearchParams?.status) ? resolvedSearchParams.status : "all";
+  const feed = await loadAgentFeed(activeStatus);
+
+  return (
+    <main className="page-shell">
+      <section className="page-title">
+        <h1>Agent 观察室</h1>
+        <p>这里直接看 OpenClaw 各个 agent 的任务、心跳、队列、主机和最近输出,方便做真实运维观察。</p>
+      </section>
+      <AgentsDashboard initialFeed={feed} initialStatus={activeStatus} />
+    </main>
+  );
+}

+ 25 - 0
app/api/agents/[agentId]/route.ts

@@ -0,0 +1,25 @@
+import { NextResponse } from "next/server";
+import { loadAgentFeed } from "@/lib/agent-monitor";
+
+type AgentRouteProps = {
+  params: Promise<{
+    agentId: string;
+  }>;
+};
+
+export async function GET(_: Request, { params }: AgentRouteProps) {
+  const { agentId } = await params;
+  const feed = await loadAgentFeed("all");
+  const agent = feed.agents.find((item) => item.id === agentId);
+
+  if (!agent) {
+    return NextResponse.json({ message: "Agent not found" }, { status: 404 });
+  }
+
+  return NextResponse.json({
+    source: feed.source,
+    sourceLabel: feed.sourceLabel,
+    fetchedAt: feed.fetchedAt,
+    agent
+  });
+}

+ 15 - 0
app/api/agents/route.ts

@@ -0,0 +1,15 @@
+import { NextResponse } from "next/server";
+import { loadAgentFeed } from "@/lib/agent-monitor";
+import { AgentStatus } from "@/types/agent";
+
+function isAgentStatus(value: string | null): value is AgentStatus {
+  return value === "working" || value === "idle" || value === "warning" || value === "offline";
+}
+
+export async function GET(request: Request) {
+  const { searchParams } = new URL(request.url);
+  const status = searchParams.get("status");
+  const feed = await loadAgentFeed(status && isAgentStatus(status) ? status : "all");
+
+  return NextResponse.json(feed);
+}

+ 51 - 0
app/api/chat/files/[storageKey]/route.ts

@@ -0,0 +1,51 @@
+import { NextRequest, NextResponse } from "next/server";
+import { basename } from "node:path";
+import { readUploadBuffer, resolveUploadPath } from "@/lib/chat-store";
+
+function guessContentType(fileName: string) {
+  const lower = fileName.toLowerCase();
+
+  if (lower.endsWith(".png")) return "image/png";
+  if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) return "image/jpeg";
+  if (lower.endsWith(".gif")) return "image/gif";
+  if (lower.endsWith(".webp")) return "image/webp";
+  if (lower.endsWith(".svg")) return "image/svg+xml";
+  if (lower.endsWith(".pdf")) return "application/pdf";
+  if (lower.endsWith(".txt")) return "text/plain; charset=utf-8";
+  if (lower.endsWith(".zip")) return "application/zip";
+  if (lower.endsWith(".rar")) return "application/vnd.rar";
+  if (lower.endsWith(".doc")) return "application/msword";
+  if (lower.endsWith(".docx")) {
+    return "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
+  }
+  if (lower.endsWith(".xls")) return "application/vnd.ms-excel";
+  if (lower.endsWith(".xlsx")) {
+    return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
+  }
+
+  return "application/octet-stream";
+}
+
+export async function GET(
+  request: NextRequest,
+  context: { params: Promise<{ storageKey: string }> }
+) {
+  const { storageKey } = await context.params;
+  const safeStorageKey = basename(storageKey);
+
+  try {
+    const fileBuffer = await readUploadBuffer(safeStorageKey);
+    const contentType = guessContentType(safeStorageKey);
+    const download = request.nextUrl.searchParams.get("download") === "1";
+
+    return new NextResponse(fileBuffer, {
+      headers: {
+        "Content-Type": contentType,
+        "Content-Disposition": `${download ? "attachment" : "inline"}; filename="${encodeURIComponent(safeStorageKey)}"`,
+        "Cache-Control": "private, max-age=31536000, immutable"
+      }
+    });
+  } catch {
+    return NextResponse.json({ error: "文件不存在" }, { status: 404 });
+  }
+}

+ 97 - 0
app/api/chat/route.ts

@@ -0,0 +1,97 @@
+import { NextRequest, NextResponse } from "next/server";
+import { writeFile } from "node:fs/promises";
+import {
+  addMessage,
+  clearMessages,
+  createStorageKey,
+  formatUploadedFileMeta,
+  getOnlineUsers,
+  listMessages,
+  resolveUploadPath,
+  touchPresence
+} from "@/lib/chat-store";
+
+export async function GET(request: NextRequest) {
+  const user = request.nextUrl.searchParams.get("user") || undefined;
+
+  touchPresence(user);
+
+  return NextResponse.json({
+    messages: listMessages(),
+    onlineUsers: getOnlineUsers()
+  });
+}
+
+export async function POST(request: NextRequest) {
+  const contentType = request.headers.get("content-type") || "";
+  let user = "局域网设备";
+  let content = "";
+  let fileRecord:
+    | {
+        name: string;
+        meta: string;
+        kind: "image" | "file";
+        storageKey: string;
+      }
+    | undefined;
+
+  if (contentType.includes("multipart/form-data")) {
+    const formData = await request.formData();
+    user = String(formData.get("user") || "局域网设备").trim() || "局域网设备";
+    content = String(formData.get("content") || "").trim();
+    const file = formData.get("file");
+
+    if (file instanceof File && file.size > 0) {
+      const storageKey = createStorageKey(file.name);
+      const buffer = Buffer.from(await file.arrayBuffer());
+      await writeFile(resolveUploadPath(storageKey), buffer);
+
+      fileRecord = {
+        name: file.name,
+        meta: formatUploadedFileMeta(file.name, file.type, file.size),
+        kind: file.type.startsWith("image/") ? "image" : "file",
+        storageKey
+      };
+    }
+  } else {
+    const payload = (await request.json()) as {
+      user?: string;
+      content?: string;
+    };
+
+    user = payload.user?.trim() || "局域网设备";
+    content = payload.content?.trim() || "";
+  }
+
+  if (!content && !fileRecord) {
+    return NextResponse.json({ error: "消息不能为空" }, { status: 400 });
+  }
+
+  touchPresence(user);
+
+  const message = addMessage({
+    user,
+    content,
+    file: fileRecord
+  });
+
+  return NextResponse.json({
+    message,
+    onlineUsers: getOnlineUsers()
+  });
+}
+
+export async function DELETE(request: NextRequest) {
+  const payload = (await request.json().catch(() => ({}))) as {
+    user?: string;
+  };
+  const user = payload.user?.trim() || "局域网设备";
+
+  touchPresence(user);
+  await clearMessages(user);
+
+  return NextResponse.json({
+    messages: listMessages(),
+    onlineUsers: getOnlineUsers()
+  });
+}

+ 636 - 0
app/chat/page.tsx

@@ -0,0 +1,636 @@
+"use client";
+
+import { ChangeEvent, ClipboardEvent, useEffect, useMemo, useRef, useState } from "react";
+
+type ChatAttachment = {
+  name: string;
+  meta: string;
+  kind: "image" | "file";
+  url?: string;
+  storageKey?: string;
+};
+
+type ChatMessage = {
+  id: string;
+  user: string;
+  time: string;
+  content: string;
+  file?: ChatAttachment;
+};
+
+type ChatResponse = {
+  messages: ChatMessage[];
+  onlineUsers: string[];
+};
+
+type PendingAttachment = {
+  file: File;
+  name: string;
+  meta: string;
+  kind: "image" | "file";
+  previewUrl?: string;
+};
+
+function detectBrowser() {
+  const ua = navigator.userAgent;
+
+  if (ua.includes("Edg/")) return "Edge";
+  if (ua.includes("Chrome/")) return "Chrome";
+  if (ua.includes("Safari/") && !ua.includes("Chrome/")) return "Safari";
+  if (ua.includes("Firefox/")) return "Firefox";
+  return "Browser";
+}
+
+function detectDevice() {
+  const ua = navigator.userAgent;
+
+  if (/iPhone/i.test(ua)) return "iPhone";
+  if (/Android/i.test(ua)) return "Android";
+  if (/Macintosh|Mac OS X/i.test(ua)) return "MacBook";
+  if (/Windows/i.test(ua)) return "Windows";
+  return "Device";
+}
+
+function createLocalName() {
+  const stored = window.localStorage.getItem("lan-chat-user");
+
+  if (stored) {
+    return stored;
+  }
+
+  const suffix = Math.random().toString(36).slice(2, 6).toUpperCase();
+  const nextValue = `${detectDevice()}-${detectBrowser()}-${suffix}`;
+  window.localStorage.setItem("lan-chat-user", nextValue);
+  return nextValue;
+}
+
+function getAvatarSeed(name: string) {
+  return name
+    .split("-")
+    .slice(0, 2)
+    .join("")
+    .slice(0, 2)
+    .toUpperCase();
+}
+
+function formatFileMeta(file: File) {
+  const sizeMb = file.size / (1024 * 1024);
+  const size = sizeMb >= 1 ? `${sizeMb.toFixed(1)} MB` : `${Math.max(1, Math.round(file.size / 1024))} KB`;
+  return `${size} · ${file.type || "application/octet-stream"}`;
+}
+
+const emojiList = ["😊", "😀", "👍", "🎉", "📎", "🔥", "✨", "✅"];
+
+export default function ChatPage() {
+  const [messages, setMessages] = useState<ChatMessage[]>([]);
+  const [onlineUsers, setOnlineUsers] = useState<string[]>([]);
+  const [draft, setDraft] = useState("");
+  const [userName, setUserName] = useState("局域网设备");
+  const [sending, setSending] = useState(false);
+  const [emojiOpen, setEmojiOpen] = useState(false);
+  const [error, setError] = useState("");
+  const [attachment, setAttachment] = useState<PendingAttachment | null>(null);
+  const [previewImage, setPreviewImage] = useState<ChatAttachment | null>(null);
+  const [downloadedFiles, setDownloadedFiles] = useState<Record<string, boolean>>({});
+  const messageListRef = useRef<HTMLElement | null>(null);
+  const fileInputRef = useRef<HTMLInputElement | null>(null);
+
+  const onlineUsersWithCurrent = useMemo(() => {
+    if (!userName || userName === "局域网设备") {
+      return onlineUsers;
+    }
+
+    return onlineUsers.includes(userName) ? onlineUsers : [userName, ...onlineUsers];
+  }, [onlineUsers, userName]);
+
+  useEffect(() => {
+    document.body.classList.add("chat-body");
+    setUserName(createLocalName());
+    const storedDownloads = window.localStorage.getItem("lan-chat-downloaded-files");
+
+    if (storedDownloads) {
+      try {
+        setDownloadedFiles(JSON.parse(storedDownloads) as Record<string, boolean>);
+      } catch {
+        window.localStorage.removeItem("lan-chat-downloaded-files");
+      }
+    }
+
+    return () => {
+      document.body.classList.remove("chat-body");
+    };
+  }, []);
+
+  useEffect(() => {
+    return () => {
+      if (attachment?.previewUrl) {
+        URL.revokeObjectURL(attachment.previewUrl);
+      }
+    };
+  }, [attachment]);
+
+  useEffect(() => {
+    if (!userName || userName === "局域网设备") {
+      return;
+    }
+
+    let cancelled = false;
+
+    const loadChat = async () => {
+      try {
+        const response = await fetch(`/api/chat?user=${encodeURIComponent(userName)}`, { cache: "no-store" });
+
+        if (!response.ok) {
+          throw new Error("加载聊天记录失败");
+        }
+
+        const data = (await response.json()) as ChatResponse;
+
+        if (cancelled) {
+          return;
+        }
+
+        setOnlineUsers(data.onlineUsers);
+        setMessages((current) => {
+          const currentLastId = current.at(-1)?.id;
+          const nextLastId = data.messages.at(-1)?.id;
+
+          if (currentLastId !== nextLastId || current.length !== data.messages.length) {
+            queueMicrotask(() => {
+              messageListRef.current?.scrollTo({
+                top: messageListRef.current.scrollHeight,
+                behavior: "auto"
+              });
+            });
+          }
+
+          return data.messages;
+        });
+        setError("");
+      } catch (loadError) {
+        if (!cancelled) {
+          setError(loadError instanceof Error ? loadError.message : "加载聊天记录失败");
+        }
+      }
+    };
+
+    void loadChat();
+    const timer = window.setInterval(() => void loadChat(), 2500);
+
+    return () => {
+      cancelled = true;
+      window.clearInterval(timer);
+    };
+  }, [userName]);
+
+  useEffect(() => {
+    if (!messages.length) {
+      return;
+    }
+
+    messageListRef.current?.scrollTo({
+      top: messageListRef.current.scrollHeight,
+      behavior: "auto"
+    });
+  }, [messages.length]);
+
+  const appendEmoji = (emoji: string) => {
+    setDraft((current) => `${current}${emoji}`);
+    setEmojiOpen(false);
+  };
+
+  const openFilePicker = () => {
+    fileInputRef.current?.click();
+  };
+
+  const clearPendingAttachment = () => {
+    if (attachment?.previewUrl) {
+      URL.revokeObjectURL(attachment.previewUrl);
+    }
+
+    setAttachment(null);
+  };
+
+  const handleClearHistory = async () => {
+    if (sending) {
+      return;
+    }
+
+    const confirmed = window.confirm("确认清理本地和服务器的历史聊天记录吗?");
+
+    if (!confirmed) {
+      return;
+    }
+
+    setSending(true);
+
+    try {
+      const response = await fetch("/api/chat", {
+        method: "DELETE",
+        headers: {
+          "Content-Type": "application/json"
+        },
+        body: JSON.stringify({
+          user: userName
+        })
+      });
+
+      if (!response.ok) {
+        throw new Error("清理聊天记录失败");
+      }
+
+      const data = (await response.json()) as ChatResponse;
+      setMessages(data.messages);
+      setOnlineUsers(data.onlineUsers);
+      setDraft("");
+      clearPendingAttachment();
+      setEmojiOpen(false);
+      setPreviewImage(null);
+      setError("");
+      setDownloadedFiles({});
+      window.localStorage.removeItem("lan-chat-downloaded-files");
+
+      queueMicrotask(() => {
+        messageListRef.current?.scrollTo({
+          top: messageListRef.current.scrollHeight,
+          behavior: "auto"
+        });
+      });
+    } catch (clearError) {
+      setError(clearError instanceof Error ? clearError.message : "清理聊天记录失败");
+    } finally {
+      setSending(false);
+    }
+  };
+
+  const applyAttachment = async (file: File) => {
+    clearPendingAttachment();
+
+    setAttachment({
+      file,
+      name: file.name,
+      meta: formatFileMeta(file),
+      kind: file.type.startsWith("image/") ? "image" : "file",
+      previewUrl: file.type.startsWith("image/") ? URL.createObjectURL(file) : undefined
+    });
+    setError("");
+  };
+
+  const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
+    const file = event.target.files?.[0];
+
+    if (!file) {
+      return;
+    }
+
+    try {
+      await applyAttachment(file);
+    } catch (fileError) {
+      setError(fileError instanceof Error ? fileError.message : "文件读取失败");
+    } finally {
+      event.target.value = "";
+    }
+  };
+
+  const handlePaste = async (event: ClipboardEvent<HTMLTextAreaElement>) => {
+    const fileItem = Array.from(event.clipboardData.items).find((item) => item.kind === "file");
+
+    if (!fileItem) {
+      return;
+    }
+
+    const file = fileItem.getAsFile();
+
+    if (!file) {
+      return;
+    }
+
+    event.preventDefault();
+
+    try {
+      await applyAttachment(file);
+    } catch (fileError) {
+      setError(fileError instanceof Error ? fileError.message : "文件读取失败");
+    }
+  };
+
+  const handleSend = async () => {
+    const content = draft.trim();
+
+    if ((!content && !attachment) || sending) {
+      return;
+    }
+
+    setSending(true);
+
+    try {
+      let response: Response;
+
+      if (attachment) {
+        const formData = new FormData();
+        formData.append("user", userName);
+        formData.append("content", content);
+        formData.append("file", attachment.file);
+
+        response = await fetch("/api/chat", {
+          method: "POST",
+          body: formData
+        });
+      } else {
+        response = await fetch("/api/chat", {
+          method: "POST",
+          headers: {
+            "Content-Type": "application/json"
+          },
+          body: JSON.stringify({
+            user: userName,
+            content
+          })
+        });
+      }
+
+      if (!response.ok) {
+        throw new Error("发送失败");
+      }
+
+      const data = (await response.json()) as { message: ChatMessage; onlineUsers: string[] };
+      setMessages((current) => [...current, data.message]);
+      setOnlineUsers(data.onlineUsers);
+      setDraft("");
+      clearPendingAttachment();
+      setEmojiOpen(false);
+      setError("");
+
+      queueMicrotask(() => {
+        messageListRef.current?.scrollTo({
+          top: messageListRef.current.scrollHeight,
+          behavior: "smooth"
+        });
+      });
+    } catch (sendError) {
+      setError(sendError instanceof Error ? sendError.message : "发送失败");
+    } finally {
+      setSending(false);
+    }
+  };
+
+  const markFileDownloaded = (key: string) => {
+    setDownloadedFiles((current) => {
+      const next = { ...current, [key]: true };
+      window.localStorage.setItem("lan-chat-downloaded-files", JSON.stringify(next));
+      return next;
+    });
+  };
+
+  const handleFileOpen = (message: ChatMessage) => {
+    if (!message.file?.url) {
+      return;
+    }
+
+    const key = `${message.id}:${message.file.storageKey || message.file.name}`;
+
+    if (downloadedFiles[key]) {
+      window.open(message.file.url, "_blank", "noopener,noreferrer");
+      return;
+    }
+
+    const link = document.createElement("a");
+    link.href = `${message.file.url}?download=1`;
+    link.download = message.file.name;
+    link.click();
+    markFileDownloaded(key);
+  };
+
+  return (
+    <main className="page-shell chat-wechat-page">
+      <section className="page-title chat-wechat-page-title">
+        <h1>局域网聊天室</h1>
+        <p>像群聊一样在同一个界面里收发文字、图片和文件,PC 端同时显示在线成员。</p>
+      </section>
+
+      <section className="chat-wechat-shell">
+        <aside className="chat-wechat-sidebar" aria-label="在线用户">
+          <div className="chat-wechat-sidebar__title">局域网在线设备</div>
+          <div className="chat-wechat-sidebar__meta">{onlineUsersWithCurrent.length} 台设备在线</div>
+
+          <div className="chat-wechat-member-list">
+            {onlineUsersWithCurrent.map((name) => (
+              <div key={name} className={`chat-wechat-member${name === userName ? " chat-wechat-member--self" : ""}`}>
+                <div className="chat-wechat-member__avatar">{getAvatarSeed(name)}</div>
+                <div className="chat-wechat-member__body">
+                  <strong>{name}</strong>
+                  <span>{name === userName ? "当前设备" : "局域网已连接"}</span>
+                </div>
+              </div>
+            ))}
+          </div>
+        </aside>
+
+        <section className="chat-wechat-main">
+          <header className="chat-wechat-header">
+            <div className="chat-wechat-header__row">
+              <div className="chat-wechat-header__main">
+                <div className="chat-wechat-header__title">局域网公共聊天室</div>
+                <div className="chat-wechat-header__meta">文字、图片、文件都可以直接在这里流转</div>
+              </div>
+
+              <div className="chat-wechat-status" aria-label="在线状态">
+                <span className="chat-wechat-status__text">{onlineUsersWithCurrent.length} 台设备在线</span>
+                <span className="chat-wechat-status__icon" aria-hidden="true">
+                  LAN
+                </span>
+              </div>
+            </div>
+
+            <div className="chat-wechat-header__presence">
+              <div className="chat-wechat-header__avatars" aria-hidden="true">
+                {onlineUsersWithCurrent.slice(0, 4).map((name) => (
+                  <span key={name} className="chat-wechat-header__avatar">
+                    {getAvatarSeed(name)}
+                  </span>
+                ))}
+              </div>
+              <div className="chat-wechat-header__presence-text">{onlineUsersWithCurrent.length} 台设备在线</div>
+            </div>
+          </header>
+
+          <section className="chat-wechat-messages" aria-label="聊天消息列表" ref={messageListRef}>
+            {messages.length === 0 ? <div className="chat-wechat-empty">聊天室已经准备好,现在发第一条消息吧。</div> : null}
+
+            {messages.map((message) => {
+              const self = message.user === userName;
+              const imageAttachment = message.file?.kind === "image" ? message.file : null;
+
+              return (
+                <article key={message.id} className={`chat-wechat-message${self ? " chat-wechat-message--self" : ""}`}>
+                  {!self ? <div className="chat-wechat-avatar">{getAvatarSeed(message.user)}</div> : null}
+
+                  <div
+                    className={`chat-wechat-message__body${
+                      imageAttachment && !message.content ? " chat-wechat-message__body--image" : ""
+                    }`}
+                  >
+                    {!self ? <div className="chat-wechat-message__name">{message.user}</div> : null}
+
+                    <div
+                      className={`chat-wechat-bubble${imageAttachment ? " chat-wechat-bubble--image" : ""}${
+                        !message.content && message.file ? " chat-wechat-bubble--attachment-only" : ""
+                      }`}
+                    >
+                      {message.content ? <div className="chat-wechat-bubble__text">{message.content}</div> : null}
+
+                      {message.file ? (
+                        <div
+                          className={`chat-wechat-file${imageAttachment ? " chat-wechat-file--image" : ""}${
+                            !imageAttachment ? " chat-wechat-file--download" : ""
+                          }`}
+                        >
+                          {imageAttachment?.url ? (
+                            <button
+                              type="button"
+                              className="chat-wechat-file__image-button"
+                              onClick={() => setPreviewImage(imageAttachment)}
+                            >
+                              <img src={imageAttachment.url} alt={message.file.name} className="chat-wechat-file__image" />
+                            </button>
+                          ) : null}
+
+                          {imageAttachment ? (
+                            <button
+                              type="button"
+                              className="chat-wechat-file__link chat-wechat-file__link--image"
+                              onClick={() => setPreviewImage(imageAttachment)}
+                            >
+                              <strong>{message.file.name}</strong>
+                            </button>
+                          ) : message.file.url ? (
+                            <button type="button" className="chat-wechat-file__download" onClick={() => handleFileOpen(message)}>
+                              <span className="chat-wechat-file__icon" aria-hidden="true">
+                                文
+                              </span>
+                              <span className="chat-wechat-file__info">
+                                <strong>{message.file.name}</strong>
+                                <span>
+                                  {downloadedFiles[`${message.id}:${message.file.storageKey || message.file.name}`]
+                                    ? "点击打开"
+                                    : "点击下载"}
+                                </span>
+                              </span>
+                            </button>
+                          ) : (
+                            <strong>{message.file.name}</strong>
+                          )}
+                          <div className="chat-wechat-file__meta">{message.file.meta}</div>
+                        </div>
+                      ) : null}
+                    </div>
+
+                    <div className="chat-wechat-message__time">{message.time}</div>
+                  </div>
+
+                  {self ? <div className="chat-wechat-avatar chat-wechat-avatar--self">我</div> : null}
+                </article>
+              );
+            })}
+          </section>
+
+          <footer className="chat-wechat-composer">
+            <input
+              ref={fileInputRef}
+              type="file"
+              hidden
+              accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.txt,.zip,.rar"
+              onChange={handleFileChange}
+            />
+
+            <div className="chat-wechat-composer__tools">
+              <div className="chat-wechat-tools-left">
+                <button
+                  className={`chat-wechat-tool${emojiOpen ? " is-active" : ""}`}
+                  type="button"
+                  aria-label="表情"
+                  onClick={() => setEmojiOpen((current) => !current)}
+                >
+                  😊
+                </button>
+                <button className="chat-wechat-tool" type="button" aria-label="选择文件" onClick={openFilePicker}>
+                  <svg className="chat-wechat-tool__icon" viewBox="0 0 24 24" aria-hidden="true">
+                    <path d="M12 5v14M5 12h14" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
+                  </svg>
+                </button>
+                <button
+                  className="chat-wechat-tool chat-wechat-tool--danger"
+                  type="button"
+                  aria-label="清理聊天记录"
+                  title="清理聊天记录"
+                  onClick={() => void handleClearHistory()}
+                >
+                  清
+                </button>
+              </div>
+
+              {error ? <div className="chat-wechat-error">{error}</div> : null}
+            </div>
+
+            {emojiOpen ? (
+              <div className="chat-wechat-emoji-panel" aria-label="表情面板">
+                {emojiList.map((emoji) => (
+                  <button key={emoji} type="button" onClick={() => appendEmoji(emoji)}>
+                    {emoji}
+                  </button>
+                ))}
+              </div>
+            ) : null}
+
+            {attachment ? (
+              <div className="chat-wechat-attachment">
+                {attachment.kind === "image" && attachment.previewUrl ? (
+                  <img src={attachment.previewUrl} alt={attachment.name} className="chat-wechat-attachment__image" />
+                ) : null}
+                <div className="chat-wechat-attachment__meta">
+                  <strong>{attachment.name}</strong>
+                  <span>{attachment.meta}</span>
+                </div>
+                <button type="button" onClick={clearPendingAttachment} aria-label="移除附件">
+                  ×
+                </button>
+              </div>
+            ) : null}
+
+            <div className="chat-wechat-composer__row">
+              <textarea
+                className="chat-wechat-input"
+                placeholder="输入消息,或直接粘贴图片 / 文件"
+                value={draft}
+                rows={1}
+                onChange={(event) => setDraft(event.target.value)}
+                onPaste={(event) => void handlePaste(event)}
+                onKeyDown={(event) => {
+                  if (event.key === "Enter" && !event.shiftKey) {
+                    event.preventDefault();
+                    void handleSend();
+                  }
+                }}
+              />
+
+              <button className="chat-wechat-send" type="button" onClick={() => void handleSend()} disabled={sending}>
+                {sending ? "发送中" : "发送"}
+              </button>
+            </div>
+          </footer>
+        </section>
+      </section>
+
+      {previewImage?.url ? (
+        <div className="chat-wechat-image-viewer" onClick={() => setPreviewImage(null)}>
+          <img
+            src={previewImage.url}
+            alt={previewImage.name}
+            className="chat-wechat-image-viewer__image"
+            onClick={(event) => event.stopPropagation()}
+          />
+        </div>
+      ) : null}
+    </main>
+  );
+}

+ 3031 - 0
app/globals.css

@@ -0,0 +1,3031 @@
+:root {
+  --bg: #f4efe7;
+  --panel: rgba(255, 252, 247, 0.88);
+  --panel-strong: #fffaf3;
+  --text: #241c16;
+  --muted: #6c6258;
+  --line: rgba(60, 42, 27, 0.12);
+  --brand: #b85c38;
+  --brand-soft: #e8c8b6;
+  --shadow: 0 20px 60px rgba(58, 35, 18, 0.12);
+  --radius-lg: 28px;
+  --radius-md: 18px;
+  --radius-sm: 12px;
+}
+
+* {
+  box-sizing: border-box;
+}
+
+html {
+  scroll-behavior: smooth;
+  scrollbar-gutter: stable;
+}
+
+body {
+  margin: 0;
+  min-width: 320px;
+  color: var(--text);
+  background:
+    radial-gradient(circle at top left, rgba(232, 200, 182, 0.7), transparent 30%),
+    radial-gradient(circle at bottom right, rgba(184, 92, 56, 0.12), transparent 28%),
+    linear-gradient(180deg, #f8f4ed 0%, var(--bg) 100%);
+  font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
+}
+
+body.reader-body {
+  overflow: hidden;
+}
+
+body.chat-body {
+  overflow: hidden;
+  overscroll-behavior: none;
+  background: var(--bg);
+}
+
+a {
+  color: inherit;
+  text-decoration: none;
+}
+
+button,
+input,
+textarea {
+  font: inherit;
+}
+
+.page-shell {
+  width: min(1200px, calc(100vw - 32px));
+  margin: 0 auto;
+}
+
+.site-header {
+  position: sticky;
+  top: 0;
+  z-index: 20;
+  backdrop-filter: blur(12px);
+  background: rgba(248, 244, 237, 0.72);
+  border-bottom: 1px solid var(--line);
+}
+
+.site-header--menu-open {
+  z-index: 40;
+}
+
+.site-header__inner {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 20px;
+  padding: 16px 0;
+}
+
+.site-brand {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.site-brand__mark {
+  width: 42px;
+  height: 42px;
+  border-radius: 14px;
+  background: linear-gradient(135deg, #c26a46, #8d4529);
+  color: white;
+  display: grid;
+  place-items: center;
+  font-weight: 700;
+  box-shadow: var(--shadow);
+}
+
+.site-brand__name {
+  font-size: 1rem;
+  font-weight: 700;
+}
+
+.site-brand__tag {
+  margin-top: 2px;
+  color: var(--muted);
+  font-size: 0.88rem;
+}
+
+.site-nav {
+  display: flex;
+  flex-wrap: wrap;
+  align-items: center;
+  justify-content: flex-end;
+  gap: 10px;
+}
+
+.site-nav a {
+  padding: 10px 14px;
+  border-radius: 999px;
+  color: var(--muted);
+}
+
+.site-nav a.is-active {
+  background: rgba(184, 92, 56, 0.1);
+  color: #8f4b2f;
+}
+
+.site-nav a:hover {
+  background: rgba(255, 250, 243, 0.92);
+  color: var(--text);
+}
+
+.site-menu-button,
+.site-drawer,
+.site-drawer-backdrop {
+  display: none;
+}
+
+.site-menu-button {
+  width: 46px;
+  height: 46px;
+  padding: 0;
+  border: 1px solid var(--line);
+  border-radius: 14px;
+  background: rgba(255, 250, 243, 0.92);
+  align-items: center;
+  justify-content: center;
+  gap: 4px;
+  cursor: pointer;
+}
+
+.site-menu-button span {
+  width: 18px;
+  height: 2px;
+  border-radius: 999px;
+  background: #6a5a4e;
+  transition: transform 0.2s ease, opacity 0.2s ease;
+}
+
+.site-menu-button.is-open span:nth-child(1) {
+  transform: translateY(6px) rotate(45deg);
+}
+
+.site-menu-button.is-open span:nth-child(2) {
+  opacity: 0;
+}
+
+.site-menu-button.is-open span:nth-child(3) {
+  transform: translateY(-6px) rotate(-45deg);
+}
+
+.site-drawer-backdrop {
+  position: fixed;
+  inset: 0;
+  background: rgba(24, 18, 13, 0.36);
+  opacity: 0;
+  pointer-events: none;
+  transition: opacity 0.2s ease;
+  z-index: 44;
+}
+
+.site-drawer-backdrop.is-open {
+  opacity: 1;
+  pointer-events: auto;
+}
+
+.site-drawer {
+  position: fixed;
+  inset: 0;
+  display: block;
+  pointer-events: none;
+  z-index: 45;
+  background: transparent;
+  opacity: 0;
+  visibility: hidden;
+  transition: opacity 0.2s ease, visibility 0.2s ease;
+}
+
+.site-drawer__panel {
+  margin-left: auto;
+  width: min(360px, 100vw);
+  height: 100%;
+  padding: 22px 18px 28px;
+  background: linear-gradient(180deg, #fbf6ef 0%, #f1e8dc 100%);
+  box-shadow: -24px 0 60px rgba(45, 31, 22, 0.18);
+  transform: translateX(100%);
+  transition: transform 0.22s ease;
+  isolation: isolate;
+}
+
+.site-drawer.is-open {
+  pointer-events: auto;
+  opacity: 1;
+  visibility: visible;
+}
+
+.site-drawer.is-open .site-drawer__panel {
+  transform: translateX(0);
+}
+
+.site-drawer__top {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 12px;
+  margin-bottom: 18px;
+}
+
+.site-drawer__title {
+  font-size: 1.2rem;
+  font-weight: 700;
+}
+
+.site-drawer__hint {
+  margin-top: 6px;
+  color: var(--muted);
+  line-height: 1.6;
+  font-size: 0.92rem;
+}
+
+.site-drawer__close {
+  min-height: 40px;
+  padding: 0 14px;
+  border: 1px solid var(--line);
+  border-radius: 999px;
+  background: rgba(255, 250, 243, 0.94);
+  cursor: pointer;
+}
+
+.site-drawer__nav {
+  display: grid;
+  gap: 10px;
+}
+
+.site-drawer__nav a {
+  min-height: 54px;
+  padding: 0 16px;
+  border-radius: 16px;
+  display: flex;
+  align-items: center;
+  background: rgba(255, 250, 243, 0.86);
+  border: 1px solid var(--line);
+}
+
+.site-drawer__nav a.is-active {
+  background: rgba(184, 92, 56, 0.12);
+  color: #8f4b2f;
+  border-color: rgba(184, 92, 56, 0.24);
+}
+
+.hero {
+  display: grid;
+  grid-template-columns: 1.15fr 0.85fr;
+  gap: 24px;
+  padding: 44px 0 26px;
+}
+
+.card {
+  background: var(--panel);
+  border: 1px solid rgba(255, 255, 255, 0.7);
+  box-shadow: var(--shadow);
+  border-radius: var(--radius-lg);
+}
+
+.hero__main {
+  padding: 36px;
+}
+
+.eyebrow {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  padding: 8px 12px;
+  border-radius: 999px;
+  background: rgba(255, 250, 243, 0.88);
+  color: var(--muted);
+  font-size: 0.9rem;
+}
+
+.hero h1 {
+  margin: 18px 0 14px;
+  font-size: clamp(2.2rem, 5vw, 4.3rem);
+  line-height: 1.05;
+}
+
+.hero p {
+  margin: 0;
+  color: var(--muted);
+  font-size: 1.03rem;
+  line-height: 1.75;
+}
+
+.hero__actions {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 12px;
+  margin-top: 28px;
+}
+
+.button {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  gap: 10px;
+  min-height: 48px;
+  padding: 0 18px;
+  border-radius: 999px;
+  border: 1px solid transparent;
+  transition: transform 0.2s ease, background 0.2s ease;
+}
+
+.button:hover {
+  transform: translateY(-1px);
+}
+
+.button--primary {
+  background: var(--brand);
+  color: white;
+}
+
+.button--secondary {
+  background: rgba(255, 250, 243, 0.92);
+  border-color: var(--line);
+}
+
+.hero__aside {
+  display: grid;
+  gap: 18px;
+}
+
+.stat-card,
+.feature-card,
+.section-card,
+.chat-layout,
+.reader-layout,
+.library-shell,
+.agents-shell {
+  background: var(--panel);
+  border: 1px solid rgba(255, 255, 255, 0.7);
+  box-shadow: var(--shadow);
+}
+
+.stat-card {
+  border-radius: 24px;
+  padding: 24px;
+}
+
+.stat-card h3,
+.feature-card h3,
+.section-card h2 {
+  margin: 0;
+}
+
+.stat-card p,
+.feature-card p,
+.section-card p {
+  color: var(--muted);
+}
+
+.home-sections {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 24px;
+  padding: 16px 0 48px;
+}
+
+.section-card {
+  padding: 28px;
+  border-radius: var(--radius-lg);
+}
+
+.section-card__list {
+  display: grid;
+  gap: 12px;
+  margin: 24px 0;
+}
+
+.section-card__item {
+  display: flex;
+  gap: 12px;
+  align-items: flex-start;
+  padding: 14px 16px;
+  border-radius: var(--radius-md);
+  background: rgba(255, 250, 243, 0.75);
+  border: 1px solid var(--line);
+}
+
+.section-card__index {
+  flex: 0 0 auto;
+  width: 28px;
+  height: 28px;
+  border-radius: 50%;
+  background: var(--brand-soft);
+  color: #6a341f;
+  display: grid;
+  place-items: center;
+  font-weight: 700;
+  font-size: 0.88rem;
+}
+
+.page-title {
+  padding: 32px 0 18px;
+}
+
+.page-title h1 {
+  margin: 0;
+  font-size: clamp(2rem, 4vw, 3.2rem);
+}
+
+.page-title p {
+  margin: 12px 0 0;
+  max-width: 720px;
+  color: var(--muted);
+  line-height: 1.7;
+}
+
+.chat-layout {
+  display: grid;
+  grid-template-columns: 280px minmax(0, 1fr);
+  gap: 0;
+  border-radius: 32px;
+  overflow: hidden;
+  margin-bottom: 40px;
+}
+
+.chat-sidebar {
+  padding: 22px;
+  border-right: 1px solid var(--line);
+  background: rgba(255, 248, 239, 0.96);
+}
+
+.chat-main {
+  display: grid;
+  grid-template-rows: auto 1fr auto;
+  min-height: 72vh;
+}
+
+.chat-toolbar,
+.chat-composer {
+  padding: 18px 22px;
+  border-bottom: 1px solid var(--line);
+}
+
+.chat-composer {
+  border-top: 1px solid var(--line);
+  border-bottom: 0;
+}
+
+.chat-messages {
+  padding: 22px;
+  display: grid;
+  gap: 16px;
+  align-content: start;
+  background:
+    linear-gradient(180deg, rgba(255, 252, 247, 0.72), rgba(255, 248, 239, 0.85)),
+    repeating-linear-gradient(
+      0deg,
+      transparent,
+      transparent 31px,
+      rgba(153, 122, 96, 0.03) 32px
+    );
+}
+
+.presence-list,
+.library-grid,
+.reader-meta,
+.reader-controls,
+.chapter-nav,
+.agents-grid,
+.agent-stats {
+  display: grid;
+  gap: 12px;
+}
+
+.presence-item,
+.message,
+.book-card,
+.chapter-card,
+.reader-panel,
+.agent-card,
+.agent-stat {
+  border: 1px solid var(--line);
+  border-radius: var(--radius-md);
+  background: rgba(255, 250, 243, 0.88);
+}
+
+.presence-item {
+  padding: 12px 14px;
+}
+
+.presence-item__meta,
+.chat-toolbar__hint,
+.message__file-meta,
+.chat-sidebar__hint {
+  color: var(--muted);
+}
+
+.chat-sidebar__hint,
+.chat-toolbar__hint {
+  margin-top: 8px;
+  line-height: 1.6;
+  font-size: 0.92rem;
+}
+
+.message {
+  padding: 14px 16px;
+  max-width: min(620px, 92%);
+  overflow-wrap: anywhere;
+}
+
+.message--self {
+  margin-left: auto;
+  background: rgba(232, 200, 182, 0.62);
+}
+
+.message__meta {
+  display: flex;
+  justify-content: space-between;
+  gap: 16px;
+  color: var(--muted);
+  font-size: 0.84rem;
+  margin-bottom: 8px;
+}
+
+.message__file {
+  margin-top: 10px;
+  padding: 12px;
+  background: rgba(255, 255, 255, 0.72);
+  border-radius: 12px;
+}
+
+.message__content {
+  line-height: 1.7;
+}
+
+.composer-grid {
+  display: grid;
+  grid-template-columns: 1fr auto;
+  gap: 14px;
+  align-items: stretch;
+}
+
+.composer-input-shell {
+  display: grid;
+  gap: 10px;
+  min-height: 100%;
+}
+
+.composer-input-hint {
+  color: var(--muted);
+  font-size: 0.9rem;
+}
+
+.composer-input {
+  min-height: 118px;
+  width: 100%;
+  resize: vertical;
+  border: 1px solid var(--line);
+  border-radius: 18px;
+  background: rgba(255, 255, 255, 0.82);
+  padding: 16px;
+}
+
+.composer-input--drop {
+  min-height: 146px;
+  border-style: dashed;
+  border-width: 1.5px;
+}
+
+.composer-actions {
+  display: grid;
+  gap: 12px;
+  align-content: stretch;
+}
+
+.composer-actions--stacked {
+  width: 228px;
+  height: 146px;
+  grid-template-rows: 1fr 1fr;
+}
+
+.composer-actions--stacked .button {
+  width: 100%;
+  height: 100%;
+  min-height: 0;
+  border-radius: 18px;
+}
+
+.library-shell {
+  padding: 22px;
+  border-radius: 32px;
+  margin-bottom: 40px;
+}
+
+.agents-shell {
+  padding: 22px;
+  border-radius: 32px;
+  margin-bottom: 40px;
+}
+
+.library-grid {
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.agents-grid {
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+}
+
+.agent-stats {
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  margin-bottom: 22px;
+}
+
+.agents-intro {
+  display: grid;
+  grid-template-columns: 1.2fr 0.8fr;
+  gap: 18px;
+  margin-bottom: 22px;
+}
+
+.agents-intro__main,
+.agents-intro__aside,
+.agent-summary-card {
+  height: 100%;
+}
+
+.agents-intro__main {
+  padding: 24px 26px;
+  border-radius: 24px;
+  border: 1px solid var(--line);
+  background: linear-gradient(180deg, rgba(255, 251, 246, 0.98), rgba(255, 245, 235, 0.9));
+}
+
+.agents-intro__main h2 {
+  margin: 14px 0 10px;
+  font-size: clamp(1.5rem, 3vw, 2rem);
+}
+
+.agents-intro__main p {
+  margin: 0;
+  color: var(--muted);
+  line-height: 1.75;
+}
+
+.agent-summary-card {
+  padding: 24px;
+  border-radius: 24px;
+  border: 1px solid rgba(148, 84, 57, 0.14);
+  background:
+    radial-gradient(circle at top right, rgba(184, 92, 56, 0.18), transparent 35%),
+    linear-gradient(180deg, rgba(255, 247, 240, 0.98), rgba(247, 236, 227, 0.94));
+}
+
+.agent-summary-card__label {
+  color: var(--muted);
+  font-size: 0.88rem;
+}
+
+.agent-summary-card strong {
+  display: block;
+  margin-top: 12px;
+  font-size: 1.45rem;
+}
+
+.agent-summary-card p {
+  margin: 12px 0 0;
+  color: var(--muted);
+  line-height: 1.7;
+}
+
+.agent-stat {
+  padding: 18px;
+}
+
+.agent-stat strong {
+  display: block;
+  font-size: 1.5rem;
+  margin-top: 8px;
+}
+
+.agent-card {
+  padding: 20px;
+  display: grid;
+  gap: 16px;
+}
+
+.agent-card--dense {
+  gap: 14px;
+  padding: 18px;
+}
+
+.agent-card-link {
+  display: block;
+}
+
+.agent-card--dashboard {
+  min-height: 100%;
+  transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
+}
+
+.agent-card--dashboard:hover {
+  transform: translateY(-3px);
+  box-shadow: 0 24px 44px rgba(76, 45, 25, 0.14);
+  border-color: rgba(184, 92, 56, 0.24);
+}
+
+.agent-card__header {
+  display: flex;
+  align-items: center;
+  gap: 14px;
+}
+
+.agent-card__name {
+  font-size: 1.1rem;
+  font-weight: 700;
+}
+
+.agent-card__role {
+  color: var(--muted);
+  font-size: 0.92rem;
+  margin-top: 4px;
+}
+
+.status-pill {
+  display: inline-flex;
+  align-items: center;
+  gap: 8px;
+  width: fit-content;
+  padding: 8px 12px;
+  border-radius: 999px;
+  font-size: 0.9rem;
+  font-weight: 600;
+}
+
+.status-pill::before {
+  content: "";
+  width: 8px;
+  height: 8px;
+  border-radius: 50%;
+  background: currentColor;
+}
+
+.status-pill--working {
+  background: rgba(74, 145, 88, 0.14);
+  color: #2f7c46;
+}
+
+.status-pill--idle {
+  background: rgba(88, 108, 132, 0.12);
+  color: #526579;
+}
+
+.status-pill--warning {
+  background: rgba(201, 140, 53, 0.14);
+  color: #9f6117;
+}
+
+.status-pill--offline {
+  background: rgba(144, 109, 101, 0.14);
+  color: #8a645e;
+}
+
+.agent-card__status-line {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+}
+
+.agent-card__updated {
+  color: var(--muted);
+  font-size: 0.88rem;
+}
+
+.agent-avatar {
+  --avatar-face: #f6dcc7;
+  --avatar-hair: #4d362c;
+  --avatar-accent: #ca764c;
+  --avatar-cloth: #d7b39c;
+  --avatar-shadow: #6f4838;
+  width: 62px;
+  height: 62px;
+  flex: 0 0 auto;
+  position: relative;
+  border-radius: 20px;
+  display: grid;
+  place-items: center;
+  background: linear-gradient(180deg, rgba(255, 248, 242, 0.96), rgba(242, 225, 210, 0.92));
+  box-shadow: 0 14px 30px rgba(89, 55, 35, 0.16);
+  border: 1px solid rgba(145, 91, 63, 0.16);
+}
+
+.agent-avatar--large {
+  width: 82px;
+  height: 82px;
+  border-radius: 24px;
+}
+
+.agent-avatar__hair {
+  position: absolute;
+  top: 12px;
+  left: 50%;
+  width: 42px;
+  height: 22px;
+  transform: translateX(-50%);
+  border-radius: 18px 18px 10px 10px;
+  background: var(--avatar-hair);
+  z-index: 2;
+}
+
+.agent-avatar__head {
+  width: 34px;
+  height: 36px;
+  position: relative;
+  z-index: 3;
+  border-radius: 14px 14px 16px 16px;
+  background: var(--avatar-face);
+  border: 3px solid var(--avatar-shadow);
+}
+
+.agent-avatar--large .agent-avatar__head {
+  width: 44px;
+  height: 46px;
+  border-radius: 16px;
+}
+
+.agent-avatar__brows {
+  position: absolute;
+  left: 8px;
+  right: 8px;
+  top: 8px;
+  display: flex;
+  justify-content: space-between;
+}
+
+.agent-avatar__brows span {
+  width: 8px;
+  height: 2px;
+  border-radius: 999px;
+  background: var(--avatar-shadow);
+}
+
+.agent-avatar__eyes {
+  position: absolute;
+  left: 7px;
+  right: 7px;
+  top: 14px;
+  display: flex;
+  justify-content: space-between;
+}
+
+.agent-avatar__eyes span {
+  width: 6px;
+  height: 8px;
+  border-radius: 6px;
+  background: var(--avatar-shadow);
+}
+
+.agent-avatar__nose {
+  position: absolute;
+  left: 50%;
+  top: 22px;
+  width: 7px;
+  height: 5px;
+  transform: translateX(-50%);
+  border-radius: 8px;
+  background: var(--avatar-accent);
+}
+
+.agent-avatar__mouth {
+  position: absolute;
+  left: 50%;
+  bottom: 7px;
+  width: 12px;
+  height: 6px;
+  transform: translateX(-50%);
+  border-bottom: 3px solid var(--avatar-shadow);
+  border-radius: 0 0 10px 10px;
+}
+
+.agent-avatar__body {
+  position: absolute;
+  bottom: 6px;
+  left: 50%;
+  width: 34px;
+  height: 14px;
+  transform: translateX(-50%);
+  border-radius: 12px 12px 6px 6px;
+  background: var(--avatar-cloth);
+  z-index: 1;
+}
+
+.agent-avatar__accent {
+  position: absolute;
+  bottom: 10px;
+  right: 10px;
+  width: 10px;
+  height: 10px;
+  border-radius: 50%;
+  background: var(--avatar-accent);
+  border: 2px solid rgba(255, 255, 255, 0.9);
+  z-index: 4;
+}
+
+.agent-avatar--female-analyst {
+  --avatar-face: #f4d8c8;
+  --avatar-hair: #4b2f2f;
+  --avatar-accent: #d76d5a;
+  --avatar-cloth: #c9d5ee;
+  --avatar-shadow: #6d4540;
+}
+
+.agent-avatar--female-analyst .agent-avatar__hair {
+  width: 44px;
+  height: 24px;
+  border-radius: 18px 18px 12px 12px;
+}
+
+.agent-avatar--male-ops {
+  --avatar-face: #efcfb9;
+  --avatar-hair: #2f2b2d;
+  --avatar-accent: #5a87c8;
+  --avatar-cloth: #cdd5df;
+  --avatar-shadow: #5b4a42;
+}
+
+.agent-avatar--male-ops .agent-avatar__hair {
+  width: 36px;
+  height: 16px;
+  top: 13px;
+  border-radius: 12px 12px 8px 8px;
+}
+
+.agent-avatar--female-researcher {
+  --avatar-face: #f0d0ba;
+  --avatar-hair: #5b3c30;
+  --avatar-accent: #d9885b;
+  --avatar-cloth: #d8c5e6;
+  --avatar-shadow: #714f46;
+}
+
+.agent-avatar--female-researcher .agent-avatar__hair {
+  width: 46px;
+  height: 26px;
+  top: 11px;
+}
+
+.agent-avatar--male-dispatcher {
+  --avatar-face: #e8c6ad;
+  --avatar-hair: #3d3533;
+  --avatar-accent: #c78153;
+  --avatar-cloth: #d7c3b6;
+  --avatar-shadow: #5c4a42;
+}
+
+.agent-avatar--male-dispatcher .agent-avatar__hair {
+  width: 38px;
+  height: 17px;
+  top: 12px;
+}
+
+.agent-avatar--female-observer {
+  --avatar-face: #f5dcca;
+  --avatar-hair: #2d313d;
+  --avatar-accent: #d46c8b;
+  --avatar-cloth: #c8d6d1;
+  --avatar-shadow: #57535a;
+}
+
+.agent-avatar--female-observer .agent-avatar__hair {
+  width: 44px;
+  height: 24px;
+}
+
+.agent-avatar--male-maintainer {
+  --avatar-face: #d9b89c;
+  --avatar-hair: #43362d;
+  --avatar-accent: #7c9b54;
+  --avatar-cloth: #d7d2bb;
+  --avatar-shadow: #634c40;
+}
+
+.agent-avatar--male-maintainer .agent-avatar__hair {
+  width: 36px;
+  height: 16px;
+  top: 13px;
+}
+
+.agent-card__meta {
+  display: grid;
+  gap: 12px;
+}
+
+.agent-quick-grid {
+  display: grid;
+  grid-template-columns: repeat(3, minmax(0, 1fr));
+  gap: 10px;
+}
+
+.agent-mini-stat {
+  padding: 12px;
+  border-radius: 14px;
+  border: 1px solid rgba(114, 80, 60, 0.12);
+  background: rgba(255, 248, 241, 0.92);
+}
+
+.agent-mini-stat span {
+  display: block;
+  color: var(--muted);
+  font-size: 0.82rem;
+}
+
+.agent-mini-stat strong {
+  display: block;
+  margin-top: 8px;
+  font-size: 1rem;
+}
+
+.agent-row {
+  display: grid;
+  grid-template-columns: 88px minmax(0, 1fr);
+  gap: 10px;
+  align-items: start;
+}
+
+.agent-row__label {
+  color: var(--muted);
+  font-size: 0.88rem;
+}
+
+.agent-row__value {
+  line-height: 1.65;
+}
+
+.agent-row__value--strong {
+  font-weight: 700;
+}
+
+.agent-card__footer {
+  padding-top: 6px;
+  color: #8c5435;
+  font-weight: 600;
+}
+
+.agents-toolbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+  padding: 0 2px 22px;
+}
+
+.agents-toolbar__hint {
+  color: var(--muted);
+  font-size: 0.92rem;
+}
+
+.agents-shell--compact {
+  padding: 20px 22px 24px;
+}
+
+.agents-monitor-top {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+  margin-bottom: 14px;
+}
+
+.agents-monitor-source {
+  display: grid;
+  gap: 6px;
+}
+
+.agents-monitor-source strong {
+  font-size: 1.02rem;
+}
+
+.agents-monitor-source span {
+  color: var(--muted);
+  font-size: 0.92rem;
+}
+
+.agents-monitor-strip {
+  display: grid;
+  grid-template-columns: repeat(5, minmax(0, 1fr));
+  gap: 10px;
+  margin-bottom: 16px;
+}
+
+.agents-monitor-strip__item {
+  min-height: 52px;
+  padding: 0 16px;
+  border: 1px solid var(--line);
+  border-radius: 16px;
+  background: rgba(255, 250, 243, 0.82);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  color: #5f544b;
+  font-weight: 600;
+}
+
+.agents-worklist {
+  display: grid;
+  grid-template-columns: repeat(4, minmax(0, 1fr));
+  gap: 14px;
+}
+
+.agents-worklist__link {
+  display: block;
+}
+
+.agents-workitem {
+  padding: 14px 14px 15px;
+  border: 1px solid var(--line);
+  border-radius: 20px;
+  background: rgba(255, 250, 243, 0.88);
+  transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.2s ease;
+  min-height: 100%;
+}
+
+.agents-workitem:hover {
+  transform: translateY(-2px);
+  box-shadow: 0 22px 40px rgba(76, 45, 25, 0.1);
+  border-color: rgba(184, 92, 56, 0.22);
+}
+
+.agents-workitem__head {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 12px;
+}
+
+.agents-workitem__identity {
+  display: flex;
+  align-items: flex-start;
+  gap: 12px;
+  min-width: 0;
+}
+
+.agents-workitem__name-row {
+  display: flex;
+  align-items: center;
+  gap: 10px;
+  flex-wrap: wrap;
+}
+
+.agents-workitem__name-row strong {
+  font-size: 1rem;
+}
+
+.agents-workitem__role {
+  margin-top: 3px;
+  color: var(--muted);
+  font-size: 0.88rem;
+}
+
+.agents-workitem__metrics {
+  display: grid;
+  gap: 4px;
+  flex-wrap: wrap;
+  justify-items: end;
+  color: var(--muted);
+  font-size: 0.82rem;
+  text-align: right;
+}
+
+.agents-workitem__task,
+.agents-workitem__output {
+  margin-top: 12px;
+  padding-top: 12px;
+  border-top: 1px solid rgba(93, 68, 53, 0.08);
+}
+
+.agents-workitem__label {
+  color: var(--muted);
+  font-size: 0.8rem;
+  margin-bottom: 5px;
+}
+
+.agents-workitem__value {
+  line-height: 1.6;
+  font-size: 0.95rem;
+  display: -webkit-box;
+  -webkit-line-clamp: 3;
+  -webkit-box-orient: vertical;
+  overflow: hidden;
+}
+
+.agents-workitem__meta {
+  display: flex;
+  align-items: center;
+  flex-wrap: wrap;
+  gap: 6px 10px;
+  margin-top: 10px;
+  color: var(--muted);
+  font-size: 0.8rem;
+}
+
+.agents-workitem__error {
+  margin-top: 7px;
+  color: #9f6117;
+  font-size: 0.82rem;
+}
+
+.filter-chip {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  min-height: 40px;
+  padding: 0 16px;
+  border-radius: 999px;
+  border: 1px solid var(--line);
+  background: rgba(255, 250, 243, 0.9);
+  color: var(--muted);
+  transition: transform 0.2s ease, border-color 0.2s ease, background 0.2s ease;
+  cursor: pointer;
+}
+
+.filter-chip:hover {
+  transform: translateY(-1px);
+  border-color: rgba(184, 92, 56, 0.26);
+}
+
+.filter-chip--active {
+  background: rgba(184, 92, 56, 0.12);
+  border-color: rgba(184, 92, 56, 0.32);
+  color: #8a472d;
+  font-weight: 700;
+}
+
+.agent-detail-hero {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 18px;
+  padding: 24px 26px;
+  border-radius: 24px;
+  border: 1px solid var(--line);
+  background: linear-gradient(180deg, rgba(255, 252, 247, 0.96), rgba(247, 238, 230, 0.92));
+  margin-bottom: 22px;
+}
+
+.agent-detail-hero__main {
+  min-width: 0;
+}
+
+.agent-detail-hero__actions {
+  flex: 0 0 auto;
+}
+
+.agent-detail-grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 18px;
+}
+
+.agent-detail-section__title {
+  margin-bottom: 14px;
+  font-size: 1rem;
+  font-weight: 700;
+}
+
+.agent-detail-list {
+  display: grid;
+  gap: 12px;
+}
+
+.agent-detail-highlight {
+  padding: 16px 18px;
+  border-radius: 18px;
+  background: rgba(184, 92, 56, 0.08);
+  border: 1px solid rgba(184, 92, 56, 0.16);
+  font-weight: 600;
+}
+
+.agent-detail-note,
+.agent-detail-paragraph {
+  margin-top: 14px;
+  color: var(--muted);
+  line-height: 1.75;
+}
+
+.book-card {
+  overflow: hidden;
+}
+
+.book-cover {
+  aspect-ratio: 3 / 4.3;
+  background:
+    linear-gradient(180deg, rgba(19, 15, 12, 0.1), rgba(19, 15, 12, 0.36)),
+    var(--cover, linear-gradient(135deg, #c06b49, #7c3c26));
+  padding: 22px;
+  color: white;
+  display: flex;
+  flex-direction: column;
+  justify-content: flex-end;
+}
+
+.book-cover__chip {
+  align-self: flex-start;
+  margin-bottom: auto;
+  padding: 8px 10px;
+  border-radius: 999px;
+  background: rgba(255, 255, 255, 0.16);
+  border: 1px solid rgba(255, 255, 255, 0.18);
+  font-size: 0.84rem;
+}
+
+.book-cover__title {
+  margin: 0;
+  font-size: 1.45rem;
+}
+
+.book-cover__author {
+  margin: 8px 0 0;
+  color: rgba(255, 255, 255, 0.85);
+}
+
+.book-body {
+  padding: 18px;
+}
+
+.book-body p {
+  color: var(--muted);
+  line-height: 1.7;
+}
+
+.reader-layout {
+  display: grid;
+  grid-template-columns: 280px minmax(0, 1fr);
+  gap: 0;
+  border-radius: 32px;
+  overflow: hidden;
+  min-height: 76vh;
+  margin-bottom: 44px;
+}
+
+.reader-sidebar {
+  padding: 24px;
+  border-right: 1px solid var(--line);
+  background: rgba(255, 248, 239, 0.96);
+}
+
+.reader-main {
+  padding: 0;
+  display: grid;
+  grid-template-rows: auto 1fr auto;
+}
+
+.reader-topbar,
+.reader-footer {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 14px;
+  padding: 18px 22px;
+  border-bottom: 1px solid var(--line);
+}
+
+.reader-footer {
+  border-top: 1px solid var(--line);
+  border-bottom: 0;
+}
+
+.reader-content {
+  padding: 0 24px 28px;
+}
+
+.reader-paper {
+  width: min(860px, 100%);
+  margin: 24px auto 0;
+  padding: clamp(24px, 4vw, 54px);
+  border-radius: 28px;
+  background: #fffdf8;
+  border: 1px solid rgba(112, 86, 62, 0.09);
+  box-shadow: 0 20px 60px rgba(54, 33, 21, 0.08);
+}
+
+.reader-paper h1 {
+  margin: 0 0 10px;
+  font-size: clamp(1.9rem, 3.5vw, 2.8rem);
+}
+
+.reader-paper h2 {
+  margin: 0 0 20px;
+  color: var(--muted);
+  font-size: 1rem;
+  font-weight: 500;
+}
+
+.reader-paper p {
+  margin: 0 0 1.3em;
+  font-size: clamp(1.02rem, 2vw, 1.14rem);
+  line-height: 2;
+  text-indent: 2em;
+}
+
+.reader-stage {
+  height: 100vh;
+  padding: 0 24px;
+  background:
+    radial-gradient(circle at top left, rgba(255, 240, 220, 0.3), transparent 28%),
+    linear-gradient(180deg, #dcd6ce 0%, #d4cec5 100%);
+  overflow: auto;
+  display: flex;
+  align-items: stretch;
+  justify-content: center;
+  position: relative;
+}
+
+.reader-desktop-layout {
+  width: fit-content;
+  max-width: 100%;
+  margin: 0 auto;
+  display: grid;
+  grid-template-columns: 78px var(--reader-shell-width, min(980px, calc(100vw - 280px))) 78px;
+  gap: 18px;
+  align-items: start;
+}
+
+.reader-qq-shell {
+  width: var(--reader-shell-width, min(980px, calc(100vw - 280px)));
+  min-height: 100vh;
+  overflow: visible;
+}
+
+.reader-mobile-bar,
+.reader-catalog-mobile-mask,
+.reader-catalog-mobile-sheet {
+  display: none;
+}
+
+.reader-qq-paper {
+  min-height: 100%;
+  background: #f7f0e4;
+  box-shadow: 0 22px 60px rgba(54, 33, 21, 0.08);
+  display: flex;
+  flex-direction: column;
+  border-radius: 0;
+}
+
+.reader-qq-paper--desktop-hidden {
+  display: none;
+}
+
+.reader-qq-header {
+  padding: 44px 72px 26px;
+  border-bottom: 1px solid rgba(53, 44, 38, 0.1);
+  text-align: center;
+}
+
+.reader-qq-header h1 {
+  margin: 0;
+  font-size: clamp(2rem, 4vw, 3rem);
+  line-height: 1.25;
+}
+
+.reader-qq-meta {
+  display: flex;
+  flex-wrap: wrap;
+  justify-content: center;
+  gap: 18px 26px;
+  margin-top: 18px;
+  color: #9c9288;
+  font-size: 0.92rem;
+}
+
+.reader-qq-content {
+  padding: 28px 72px 80px;
+  flex: 1 1 auto;
+}
+
+.reader-qq-content p {
+  margin: 0 0 1.6em;
+  color: #2d2621;
+  font-size: clamp(1.14rem, 2vw, 1.28rem);
+  line-height: 2.35;
+  text-indent: 2em;
+}
+
+.reader-float {
+  position: sticky;
+  top: 160px;
+  z-index: 15;
+  display: grid;
+  gap: 10px;
+}
+
+.reader-float--left,
+.reader-float--right {
+  width: 78px;
+}
+
+.reader-progress-rail {
+  position: fixed;
+  top: 0;
+  right: 0;
+  width: 12px;
+  height: 100vh;
+  z-index: 16;
+  pointer-events: none;
+}
+
+.reader-progress-rail__track {
+  width: 4px;
+  height: calc(100vh - 24px);
+  margin: 12px 4px 12px auto;
+  border-radius: 999px;
+  background: rgba(86, 70, 56, 0.16);
+  overflow: hidden;
+}
+
+.reader-progress-rail__fill {
+  width: 100%;
+  height: 0;
+  margin-top: auto;
+  background: linear-gradient(180deg, #c78255, #a45834);
+}
+
+.reader-float__button {
+  width: 78px;
+  min-height: 78px;
+  padding: 12px 10px;
+  border: 0;
+  border-radius: 12px;
+  background: rgba(255, 255, 255, 0.92);
+  box-shadow: 0 10px 26px rgba(40, 28, 20, 0.08);
+  display: grid;
+  align-content: center;
+  justify-items: center;
+  gap: 6px;
+  color: #5f544b;
+  cursor: pointer;
+  transition: transform 0.18s ease, background 0.18s ease;
+}
+
+.reader-float__button:hover {
+  transform: translateY(-1px);
+  background: rgba(255, 250, 243, 0.98);
+}
+
+.reader-float__button strong {
+  font-size: 1rem;
+}
+
+.reader-float__button span {
+  font-size: 0.82rem;
+}
+
+.reader-catalog-inline {
+  min-height: 100%;
+  background: #e1d6c8;
+  padding: 28px 40px 36px;
+  box-shadow: 0 22px 60px rgba(54, 33, 21, 0.08);
+  border-radius: 0;
+}
+
+.reader-catalog__header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+  margin-bottom: 18px;
+}
+
+.reader-catalog__header h2 {
+  margin: 0;
+  font-size: 2rem;
+}
+
+.reader-catalog__close {
+  border: 0;
+  background: transparent;
+  color: var(--muted);
+  cursor: pointer;
+}
+
+.reader-catalog__grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 12px 26px;
+}
+
+.reader-catalog__item {
+  display: grid;
+  gap: 8px;
+  padding: 14px 0;
+  border-bottom: 1px solid rgba(91, 71, 56, 0.08);
+}
+
+.reader-catalog__item span {
+  color: var(--muted);
+  font-size: 0.9rem;
+}
+
+.reader-catalog__item strong {
+  font-size: 1.06rem;
+  font-weight: 600;
+}
+
+.reader-catalog__item--active {
+  color: #ba5f39;
+  font-weight: 700;
+}
+
+.reader-qq-footer {
+  display: grid;
+  grid-template-columns: 180px 1fr 1fr;
+  gap: 18px;
+  padding: 0 72px 48px;
+  margin-top: auto;
+}
+
+.reader-qq-footer__ghost,
+.reader-qq-footer__button {
+  min-height: 54px;
+  border-radius: 999px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  border: 0;
+}
+
+.reader-qq-footer__ghost {
+  background: rgba(198, 141, 108, 0.14);
+  color: #9a5e3d;
+}
+
+.reader-qq-footer__button {
+  background: linear-gradient(180deg, #6ab1ff, #4f8adb);
+  color: white;
+}
+
+.reader-qq-footer__button--disabled {
+  background: rgba(129, 164, 205, 0.34);
+  color: rgba(255, 255, 255, 0.86);
+}
+
+.chip-row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 10px;
+}
+
+.chip {
+  padding: 8px 12px;
+  border-radius: 999px;
+  background: rgba(255, 250, 243, 0.92);
+  border: 1px solid var(--line);
+  color: var(--muted);
+  font-size: 0.9rem;
+}
+
+.chat-wechat-page {
+  min-height: calc(100vh - 74px);
+  height: calc(100vh - 74px);
+  padding: 0 0 24px;
+  overflow: hidden;
+  overscroll-behavior: none;
+  display: grid;
+  grid-template-rows: auto minmax(0, 1fr);
+  gap: 0;
+}
+
+.chat-wechat-page-title {
+  padding-bottom: 18px;
+}
+
+.chat-wechat-shell {
+  width: 100%;
+  min-height: 0;
+  height: 100%;
+  border-radius: 30px;
+  overflow: hidden;
+  border: 1px solid rgba(255, 255, 255, 0.72);
+  background: rgba(255, 252, 248, 0.9);
+  box-shadow: var(--shadow);
+  display: grid;
+  grid-template-columns: 280px minmax(0, 1fr);
+}
+
+.chat-wechat-sidebar {
+  padding: 22px 18px;
+  border-right: 1px solid var(--line);
+  background: rgba(255, 248, 240, 0.92);
+  display: grid;
+  grid-template-rows: auto auto minmax(0, 1fr);
+  gap: 8px;
+}
+
+.chat-wechat-sidebar__title {
+  font-size: 1.12rem;
+  font-weight: 700;
+}
+
+.chat-wechat-sidebar__meta {
+  color: var(--muted);
+  font-size: 0.9rem;
+}
+
+.chat-wechat-member-list {
+  display: grid;
+  gap: 10px;
+  align-content: start;
+  overflow: auto;
+  padding-right: 4px;
+}
+
+.chat-wechat-member {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  padding: 12px;
+  border-radius: 18px;
+  border: 1px solid rgba(91, 71, 56, 0.08);
+  background: rgba(255, 255, 255, 0.76);
+}
+
+.chat-wechat-member--self {
+  background: rgba(184, 92, 56, 0.12);
+  border-color: rgba(184, 92, 56, 0.18);
+}
+
+.chat-wechat-member__body {
+  min-width: 0;
+  display: grid;
+  gap: 4px;
+}
+
+.chat-wechat-member__body strong {
+  font-size: 0.96rem;
+  white-space: nowrap;
+  overflow: hidden;
+  text-overflow: ellipsis;
+}
+
+.chat-wechat-member__body span {
+  color: var(--muted);
+  font-size: 0.84rem;
+}
+
+.chat-wechat-main {
+  min-width: 0;
+  min-height: 0;
+  overflow: hidden;
+  display: grid;
+  grid-template-rows: auto minmax(0, 1fr) auto;
+}
+
+.chat-wechat-header {
+  padding: 22px 24px 18px;
+  border-bottom: 1px solid var(--line);
+  background: rgba(255, 248, 240, 0.92);
+}
+
+.chat-wechat-header__row {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 16px;
+}
+
+.chat-wechat-header__title {
+  font-size: 1.5rem;
+  font-weight: 700;
+}
+
+.chat-wechat-header__meta {
+  margin-top: 6px;
+  color: var(--muted);
+}
+
+.chat-wechat-header__presence {
+  display: none;
+}
+
+.chat-wechat-header__avatars {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+}
+
+.chat-wechat-header__avatar {
+  width: 28px;
+  height: 28px;
+  border-radius: 10px;
+  border: 2px solid rgba(255, 248, 240, 0.96);
+  background: rgba(184, 92, 56, 0.16);
+  color: #9a583a;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 0.7rem;
+  font-weight: 700;
+}
+
+.chat-wechat-header__presence-text {
+  color: var(--muted);
+  font-size: 0.84rem;
+}
+
+.chat-wechat-status {
+  display: inline-flex;
+  align-items: center;
+  gap: 10px;
+  flex: 0 0 auto;
+}
+
+.chat-wechat-status__text {
+  color: var(--muted);
+  font-size: 0.92rem;
+  white-space: nowrap;
+}
+
+.chat-wechat-status__icon {
+  min-width: 34px;
+  height: 34px;
+  padding: 0 10px;
+  border-radius: 12px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  background: rgba(184, 92, 56, 0.12);
+  color: #9a583a;
+  font-size: 0.72rem;
+  font-weight: 700;
+}
+
+.chat-wechat-member__avatar,
+.chat-wechat-avatar {
+  width: 38px;
+  height: 38px;
+  border-radius: 14px;
+  display: grid;
+  place-items: center;
+  background: rgba(184, 92, 56, 0.16);
+  color: #9a583a;
+  font-size: 0.88rem;
+  font-weight: 700;
+}
+
+.chat-wechat-messages {
+  padding: 22px 24px;
+  display: grid;
+  gap: 16px;
+  align-content: start;
+  overflow: auto;
+  min-height: 0;
+  background:
+    linear-gradient(180deg, rgba(255, 252, 248, 0.94), rgba(250, 245, 238, 0.98));
+}
+
+.chat-wechat-empty {
+  padding: 18px;
+  border-radius: 18px;
+  text-align: center;
+  color: var(--muted);
+  background: rgba(255, 255, 255, 0.72);
+}
+
+.chat-wechat-message {
+  display: flex;
+  align-items: flex-start;
+  gap: 12px;
+}
+
+.chat-wechat-message--self {
+  justify-content: flex-end;
+}
+
+.chat-wechat-avatar--self {
+  background: rgba(110, 154, 90, 0.18);
+  color: #4f7740;
+}
+
+.chat-wechat-message__body {
+  max-width: min(620px, 100%);
+  display: inline-flex;
+  flex-direction: column;
+  align-items: flex-start;
+}
+
+.chat-wechat-message__body--image {
+  max-width: min(320px, 100%);
+}
+
+.chat-wechat-message__name,
+.chat-wechat-message__time {
+  color: var(--muted);
+  font-size: 0.82rem;
+}
+
+.chat-wechat-message__name {
+  margin-bottom: 6px;
+}
+
+.chat-wechat-message__time {
+  margin-top: 6px;
+}
+
+.chat-wechat-message--self .chat-wechat-message__time {
+  text-align: right;
+  width: 100%;
+}
+
+.chat-wechat-bubble {
+  display: inline-block;
+  width: fit-content;
+  max-width: 100%;
+  padding: 14px 16px;
+  border-radius: 8px 18px 18px 18px;
+  background: #ffffff;
+  border: 1px solid rgba(91, 71, 56, 0.08);
+  box-shadow: 0 8px 18px rgba(45, 31, 22, 0.04);
+}
+
+.chat-wechat-message--self .chat-wechat-bubble {
+  border-radius: 18px 8px 18px 18px;
+  background: #dff2d8;
+}
+
+.chat-wechat-bubble__text {
+  line-height: 1.75;
+  overflow-wrap: anywhere;
+}
+
+.chat-wechat-bubble--attachment-only {
+  padding: 10px;
+}
+
+.chat-wechat-bubble--image {
+  padding: 10px;
+  max-width: min(320px, 100%);
+}
+
+.chat-wechat-file {
+  margin-top: 10px;
+  padding: 12px;
+  border-radius: 14px;
+  background: rgba(255, 255, 255, 0.8);
+}
+
+.chat-wechat-file--image {
+  margin-top: 0;
+  padding: 0;
+  background: transparent;
+  display: inline-grid;
+  width: fit-content;
+  max-width: 100%;
+}
+
+.chat-wechat-file--download {
+  min-width: 260px;
+}
+
+.chat-wechat-file__image-button {
+  display: inline-block;
+  padding: 0;
+  border: 0;
+  background: transparent;
+  cursor: pointer;
+}
+
+.chat-wechat-file__image {
+  width: min(280px, 100%);
+  display: block;
+  margin-bottom: 10px;
+  border-radius: 12px;
+  object-fit: cover;
+}
+
+.chat-wechat-file__meta {
+  margin-top: 6px;
+  color: var(--muted);
+  font-size: 0.86rem;
+}
+
+.chat-wechat-file__link {
+  color: inherit;
+  text-decoration: none;
+  border: 0;
+  background: transparent;
+  padding: 0;
+  text-align: left;
+  cursor: pointer;
+}
+
+.chat-wechat-file__link strong {
+  display: inline-block;
+}
+
+.chat-wechat-file__download {
+  width: 100%;
+  display: grid;
+  grid-template-columns: 38px minmax(0, 1fr);
+  gap: 10px;
+  align-items: center;
+  padding: 10px 12px;
+  border: 0;
+  border-radius: 14px;
+  background: rgba(255, 255, 255, 0.78);
+  cursor: pointer;
+  text-align: left;
+}
+
+.chat-wechat-file__icon {
+  width: 38px;
+  height: 38px;
+  border-radius: 12px;
+  background: rgba(184, 92, 56, 0.14);
+  color: #9a583a;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 0.88rem;
+  font-weight: 700;
+}
+
+.chat-wechat-file__info {
+  display: grid;
+  gap: 4px;
+}
+
+.chat-wechat-file__info span {
+  color: var(--muted);
+  font-size: 0.82rem;
+}
+
+.chat-wechat-composer {
+  padding: 16px 18px 18px;
+  border-top: 1px solid var(--line);
+  background: rgba(249, 244, 236, 0.98);
+}
+
+.chat-wechat-composer__tools {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 10px;
+  margin-bottom: 10px;
+}
+
+.chat-wechat-tools-left {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+}
+
+.chat-wechat-composer__row {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) 74px;
+  gap: 10px;
+  align-items: stretch;
+}
+
+.chat-wechat-tool,
+.chat-wechat-send {
+  min-height: 44px;
+  border: 0;
+  border-radius: 14px;
+}
+
+.chat-wechat-tool {
+  background: rgba(255, 255, 255, 0.88);
+  cursor: pointer;
+  width: 44px;
+  min-width: 44px;
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 1.1rem;
+}
+
+.chat-wechat-tool__icon {
+  width: 18px;
+  height: 18px;
+  display: block;
+}
+
+.chat-wechat-send {
+  background: #7bb463;
+  color: white;
+  cursor: pointer;
+}
+
+.chat-wechat-input {
+  min-height: 44px;
+  height: 44px;
+  max-height: 120px;
+  width: 100%;
+  resize: none;
+  border: 0;
+  border-radius: 16px;
+  background: rgba(255, 255, 255, 0.96);
+  padding: 12px 14px;
+}
+
+.chat-wechat-tool.is-active {
+  background: rgba(123, 180, 99, 0.16);
+  color: #467536;
+}
+
+.chat-wechat-tool--danger {
+  color: #b24a2f;
+  background: rgba(214, 108, 77, 0.12);
+  font-size: 0;
+  position: relative;
+}
+
+.chat-wechat-tool--danger::before {
+  content: "";
+  width: 18px;
+  height: 18px;
+  display: block;
+  background-repeat: no-repeat;
+  background-position: center;
+  background-size: 18px 18px;
+  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23b24a2f' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M9 4.5h6m-8 3h10m-8.5 0 .6 10.2c.1 1 1 1.8 2 1.8h2.8c1 0 1.9-.8 2-1.8l.6-10.2M10 10.5v5.5M14 10.5v5.5'/%3E%3C/svg%3E");
+}
+
+.chat-wechat-emoji-panel {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 8px;
+  padding: 10px;
+  margin-bottom: 10px;
+  border-radius: 18px;
+  background: rgba(255, 255, 255, 0.76);
+}
+
+.chat-wechat-emoji-panel button {
+  width: 40px;
+  height: 40px;
+  border: 0;
+  border-radius: 12px;
+  background: rgba(245, 239, 231, 0.96);
+  cursor: pointer;
+}
+
+.chat-wechat-error {
+  color: #b24a2f;
+  font-size: 0.86rem;
+}
+
+.chat-wechat-attachment {
+  display: grid;
+  grid-template-columns: auto minmax(0, 1fr) 36px;
+  gap: 10px;
+  align-items: center;
+  padding: 10px 12px;
+  margin-bottom: 10px;
+  border-radius: 16px;
+  background: rgba(255, 255, 255, 0.78);
+}
+
+.chat-wechat-attachment__image {
+  width: 44px;
+  height: 44px;
+  border-radius: 10px;
+  object-fit: cover;
+}
+
+.chat-wechat-attachment__meta {
+  display: grid;
+  gap: 4px;
+}
+
+.chat-wechat-attachment__meta span {
+  color: var(--muted);
+  font-size: 0.84rem;
+}
+
+.chat-wechat-attachment button {
+  width: 36px;
+  height: 36px;
+  border: 0;
+  border-radius: 10px;
+  background: rgba(184, 92, 56, 0.12);
+  color: #9a583a;
+  cursor: pointer;
+}
+
+.chat-wechat-send:disabled {
+  opacity: 0.72;
+  cursor: default;
+}
+
+.chat-wechat-image-viewer {
+  position: fixed;
+  inset: 0;
+  z-index: 60;
+  display: grid;
+  place-items: center;
+  padding: 20px;
+  background: rgba(14, 12, 10, 0.88);
+}
+
+.chat-wechat-image-viewer__image {
+  max-width: min(92vw, 980px);
+  max-height: 92vh;
+  object-fit: contain;
+  border-radius: 16px;
+}
+
+@media (max-width: 960px) {
+  .site-nav {
+    display: none;
+  }
+
+  .site-menu-button {
+    display: inline-flex;
+    flex-direction: column;
+  }
+
+  .site-drawer,
+  .site-drawer-backdrop {
+    display: block;
+  }
+
+  body.reader-body {
+    overflow: hidden;
+  }
+
+  .hero,
+  .home-sections,
+  .chat-layout,
+  .reader-layout {
+    grid-template-columns: 1fr;
+  }
+
+  .chat-sidebar,
+  .reader-sidebar {
+    border-right: 0;
+    border-bottom: 1px solid var(--line);
+  }
+
+  .library-grid {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  .agents-grid {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  .agent-stats {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  .agents-intro,
+  .agent-detail-grid {
+    grid-template-columns: 1fr;
+  }
+
+  .agents-monitor-top {
+    flex-direction: column;
+    align-items: stretch;
+  }
+
+  .agents-monitor-strip {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  .agents-worklist {
+    grid-template-columns: repeat(2, minmax(0, 1fr));
+  }
+
+  .agents-workitem__head {
+    flex-direction: column;
+    align-items: stretch;
+  }
+
+  .agents-workitem__metrics {
+    justify-content: flex-start;
+  }
+
+  .reader-stage {
+    height: 100dvh;
+    padding: 72px 0 86px;
+    overflow: auto;
+    display: block;
+    background: var(--reader-page) !important;
+  }
+
+  .reader-desktop-layout {
+    display: block;
+    width: auto;
+    max-width: none;
+  }
+
+  .reader-qq-shell {
+    width: 100% !important;
+    min-height: calc(100dvh - 158px);
+    overflow: visible;
+    background: var(--reader-page);
+  }
+
+  .reader-float {
+    display: none;
+  }
+
+  .reader-progress-rail {
+    display: none;
+  }
+
+  .reader-catalog__grid,
+  .reader-qq-footer {
+    grid-template-columns: 1fr;
+  }
+
+  .reader-mobile-bar {
+    position: fixed;
+    left: 0;
+    right: 0;
+    z-index: 28;
+    display: grid;
+    gap: 0;
+    padding: 10px 14px;
+    background: var(--reader-page);
+    backdrop-filter: blur(14px);
+  }
+
+  .reader-mobile-bar--top {
+    top: 0;
+    grid-template-columns: 1fr 56px 56px;
+    align-items: center;
+    border-bottom: 1px solid rgba(91, 71, 56, 0.08);
+  }
+
+  .reader-mobile-bar--bottom {
+    bottom: 0;
+    grid-template-columns: repeat(3, minmax(0, 1fr));
+    border-top: 1px solid rgba(91, 71, 56, 0.08);
+  }
+
+  .reader-mobile-bar__icon,
+  .reader-mobile-bar__action {
+    min-height: 42px;
+    padding: 0 12px;
+    border: 0;
+    border-radius: 0;
+    background: transparent;
+    box-shadow: none;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    color: #54483f;
+  }
+
+.reader-mobile-bar__icon {
+  font-size: 0.92rem;
+  font-weight: 600;
+  white-space: nowrap;
+}
+
+.reader-mobile-bar__icon--back,
+.reader-mobile-bar__icon:first-child {
+  justify-content: flex-start;
+  padding-left: 4px;
+}
+
+  .reader-mobile-bar__action {
+    font-size: 0.9rem;
+    border-right: 1px solid rgba(91, 71, 56, 0.08);
+  }
+
+  .reader-mobile-bar__action:last-child {
+    border-right: 0;
+  }
+
+  .reader-mobile-bar__action--disabled {
+    color: #a99a8d;
+  }
+
+  .reader-qq-paper,
+  .reader-catalog-inline {
+    min-height: calc(100dvh - 158px);
+  }
+
+  .reader-qq-paper--desktop-hidden {
+    display: flex;
+  }
+
+  .reader-qq-paper {
+    box-shadow: none;
+    background: var(--reader-page) !important;
+  }
+
+  .reader-qq-header {
+    padding: 28px 20px 18px;
+    background: var(--reader-page);
+  }
+
+  .reader-qq-content {
+    padding: 20px 20px 28px;
+    background: var(--reader-page);
+  }
+
+  .reader-qq-content p {
+    margin-bottom: 1.35em;
+    font-size: clamp(1rem, 4vw, 1.12rem);
+    line-height: 2.08;
+  }
+
+  .reader-catalog-inline {
+    padding: 22px 20px 28px;
+  }
+
+  .reader-catalog-mobile-mask {
+    position: fixed;
+    inset: 0;
+    display: block;
+    background: rgba(28, 21, 16, 0.38);
+    z-index: 26;
+  }
+
+  .reader-catalog-mobile-mask[hidden] {
+    display: none;
+  }
+
+  .reader-catalog-mobile-sheet {
+    position: fixed;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    display: block;
+    max-height: min(72dvh, 680px);
+    padding: 18px 18px 28px;
+    border-radius: 26px 26px 0 0;
+    box-shadow: 0 -20px 40px rgba(28, 21, 16, 0.2);
+    transform: translateY(102%);
+    transition: transform 0.22s ease;
+    z-index: 27;
+    overflow: auto;
+    pointer-events: none;
+  }
+
+  .reader-catalog-mobile-sheet.is-open {
+    transform: translateY(0);
+    pointer-events: auto;
+  }
+
+  .reader-catalog-mobile-sheet .reader-catalog__header {
+    position: sticky;
+    top: 0;
+    z-index: 1;
+    background: inherit;
+  }
+
+  .reader-catalog-inline {
+    display: none;
+  }
+
+  .reader-catalog__header {
+    position: sticky;
+    top: 0;
+    padding-bottom: 14px;
+    background: inherit;
+    z-index: 1;
+  }
+
+  .reader-qq-footer {
+    padding: 0 20px 22px;
+    gap: 12px;
+  }
+
+  .reader-qq-footer {
+    display: none;
+  }
+
+  .reader-qq-footer__ghost,
+  .reader-qq-footer__button {
+    min-height: 48px;
+  }
+
+  .agent-quick-grid {
+    grid-template-columns: repeat(3, minmax(0, 1fr));
+  }
+}
+
+@media (max-width: 640px) {
+  body.chat-body {
+    overflow: hidden;
+    position: static;
+    width: auto;
+    min-height: 100svh;
+  }
+
+  .chat-wechat-page {
+    padding: 0;
+    height: calc(100svh - 74px);
+    min-height: calc(100svh - 74px);
+    overflow: hidden;
+    width: 100%;
+    max-width: 100%;
+    min-width: 0;
+    margin: 0;
+    display: grid;
+    grid-template-rows: minmax(0, 1fr);
+    gap: 0;
+    background: #efeae2;
+  }
+
+  .chat-wechat-page-title {
+    display: none;
+  }
+
+  .chat-wechat-shell {
+    width: 100%;
+    min-height: 0;
+    height: 100%;
+    border-radius: 0;
+    border: 0;
+    box-shadow: none;
+    background: #efeae2;
+    display: grid;
+    grid-template-columns: 1fr;
+    grid-template-rows: minmax(0, 1fr);
+  }
+
+  .chat-wechat-main {
+    height: 100%;
+    min-height: 0;
+  }
+
+  .chat-wechat-sidebar {
+    display: none;
+  }
+
+  .chat-wechat-header {
+    padding: 14px 16px 10px;
+    background: #f6efe6;
+  }
+
+  .chat-wechat-header__row {
+    align-items: center;
+    justify-content: space-between;
+    gap: 10px;
+  }
+
+  .chat-wechat-header__main {
+    min-width: 0;
+    flex: 1 1 auto;
+    display: flex;
+    align-items: baseline;
+    justify-content: space-between;
+    gap: 10px;
+  }
+
+  .chat-wechat-header__title {
+    font-size: 1.15rem;
+    white-space: nowrap;
+  }
+
+  .chat-wechat-header__meta {
+    margin-top: 0;
+    font-size: 0.78rem;
+    white-space: nowrap;
+    text-align: right;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+
+  .chat-wechat-status {
+    display: none;
+  }
+
+  .chat-wechat-status__text {
+    font-size: 0.84rem;
+  }
+
+  .chat-wechat-status__icon {
+    min-width: 30px;
+    height: 30px;
+    padding: 0 8px;
+    border-radius: 10px;
+  }
+
+  .chat-wechat-messages {
+    padding: 14px 12px 18px;
+    gap: 12px;
+    background: #efeae2;
+    overflow: auto;
+    min-height: 0;
+    padding-bottom: 18px;
+  }
+
+  .chat-wechat-header__presence {
+    margin-top: 10px;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 12px;
+  }
+
+  .chat-wechat-header__avatars {
+    gap: 8px;
+  }
+
+  .chat-wechat-message {
+    gap: 8px;
+  }
+
+  .chat-wechat-avatar {
+    width: 34px;
+    height: 34px;
+    border-radius: 12px;
+  }
+
+  .chat-wechat-message__body {
+    max-width: calc(100% - 48px);
+  }
+
+  .chat-wechat-bubble {
+    padding: 12px 13px;
+    border-radius: 6px 16px 16px 16px;
+    box-shadow: none;
+  }
+
+  .chat-wechat-message--self .chat-wechat-bubble {
+    border-radius: 16px 6px 16px 16px;
+  }
+
+  .chat-wechat-composer {
+    padding: 10px 12px calc(12px + env(safe-area-inset-bottom));
+    background: rgba(245, 239, 231, 0.98);
+    backdrop-filter: blur(12px);
+    z-index: 6;
+    margin-top: 0;
+    position: relative;
+    box-shadow: 0 -10px 26px rgba(49, 34, 22, 0.06);
+  }
+
+  .chat-wechat-composer::after {
+    content: "";
+    position: absolute;
+    left: 0;
+    right: 0;
+    bottom: calc(-1 * env(safe-area-inset-bottom));
+    height: env(safe-area-inset-bottom);
+    background: rgba(245, 239, 231, 0.98);
+  }
+
+  .chat-wechat-composer__tools {
+    margin-bottom: 8px;
+  }
+
+  .chat-wechat-tool,
+  .chat-wechat-send {
+    min-height: 42px;
+  }
+
+  .chat-wechat-tool {
+    width: 42px;
+    min-width: 42px;
+    border-radius: 12px;
+  }
+
+  .chat-wechat-composer__row {
+    grid-template-columns: minmax(0, 1fr) 72px;
+    gap: 8px;
+  }
+
+  .chat-wechat-input {
+    min-height: 42px;
+    height: 42px;
+    padding: 10px 12px;
+    border-radius: 14px;
+  }
+
+  .chat-wechat-emoji-panel {
+    padding: 8px;
+    border-radius: 16px;
+  }
+
+  .chat-wechat-emoji-panel button {
+    width: 38px;
+    height: 38px;
+    border-radius: 10px;
+  }
+
+  .chat-wechat-attachment {
+    grid-template-columns: auto minmax(0, 1fr) 32px;
+    padding: 9px 10px;
+  }
+
+  .chat-wechat-attachment__image {
+    width: 40px;
+    height: 40px;
+  }
+
+  .chat-wechat-attachment button {
+    width: 32px;
+    height: 32px;
+  }
+
+  .chat-wechat-image-viewer {
+    padding: 14px;
+  }
+
+  .chat-wechat-image-viewer__image {
+    max-width: calc(100vw - 28px);
+    max-height: calc(100svh - 28px);
+  }
+
+  .site-header {
+    position: sticky;
+    top: 0;
+    background: #fbf6ef;
+    backdrop-filter: none;
+  }
+
+  .page-shell {
+    width: min(100vw - 20px, 100%);
+  }
+
+  .site-header__inner {
+    padding: 12px 0;
+  }
+
+  .site-drawer,
+  .site-drawer-backdrop {
+    display: block;
+  }
+
+  .site-drawer {
+    background: transparent;
+  }
+
+  .site-drawer__panel {
+    margin-left: 0;
+    width: 100vw;
+    min-width: 0;
+    min-height: 100dvh;
+    height: 100dvh;
+    padding-top: 88px;
+    background: #fbf6ef;
+    box-shadow: none;
+  }
+
+  .site-brand__name {
+    color: #241c16;
+  }
+
+  .site-menu-button {
+    border-color: rgba(60, 42, 27, 0.14);
+    background: #fff8f0;
+  }
+
+  .hero {
+    padding-top: 18px;
+    gap: 16px;
+  }
+
+  .hero__main,
+  .section-card,
+  .library-shell,
+  .agents-shell,
+  .chat-sidebar,
+  .chat-toolbar,
+  .chat-messages,
+  .chat-composer,
+  .reader-sidebar,
+  .reader-topbar,
+  .reader-footer,
+  .reader-content {
+    padding-left: 16px;
+    padding-right: 16px;
+  }
+
+  .library-grid {
+    grid-template-columns: 1fr;
+  }
+
+  .book-card {
+    display: grid;
+    grid-template-columns: 112px minmax(0, 1fr);
+  }
+
+  .book-cover {
+    aspect-ratio: auto;
+    min-height: 100%;
+    padding: 16px;
+  }
+
+  .book-cover__title {
+    font-size: 1.1rem;
+  }
+
+  .agents-grid {
+    grid-template-columns: 1fr;
+  }
+
+  .agent-stats {
+    grid-template-columns: 1fr;
+  }
+
+  .agents-monitor-strip {
+    grid-template-columns: 1fr;
+  }
+
+  .agents-worklist {
+    grid-template-columns: 1fr;
+  }
+
+  .agents-workitem {
+    padding: 14px 14px 16px;
+    border-radius: 18px;
+  }
+
+  .agents-workitem__identity {
+    align-items: flex-start;
+  }
+
+  .agents-workitem__name-row {
+    gap: 8px;
+  }
+
+  .agents-workitem__metrics,
+  .agents-workitem__meta {
+    gap: 8px 10px;
+  }
+
+  .reader-qq-header {
+    padding: 24px 16px 16px;
+  }
+
+  .reader-qq-header h1 {
+    font-size: clamp(1.6rem, 8vw, 2.1rem);
+  }
+
+  .reader-qq-meta {
+    justify-content: flex-start;
+    gap: 8px 14px;
+    font-size: 0.84rem;
+  }
+
+  .reader-catalog-inline {
+    padding: 18px 16px 24px;
+  }
+
+  .reader-catalog__header h2 {
+    font-size: 1.5rem;
+  }
+
+  .reader-qq-footer {
+    padding-left: 16px;
+    padding-right: 16px;
+  }
+
+  .agent-quick-grid {
+    grid-template-columns: 1fr;
+  }
+
+  .agents-toolbar,
+  .agent-detail-hero {
+    flex-direction: column;
+    align-items: stretch;
+  }
+
+  .agent-row {
+    grid-template-columns: 1fr;
+    gap: 6px;
+  }
+
+  .composer-grid {
+    grid-template-columns: 1fr;
+  }
+
+  .chat-layout {
+    border-radius: 24px;
+    overflow: hidden;
+    background: rgba(255, 250, 243, 0.92);
+    margin-bottom: 18px;
+  }
+
+  .page-title--compact-mobile {
+    padding: 14px 0 10px;
+  }
+
+  .page-title--compact-mobile h1 {
+    font-size: 2rem;
+  }
+
+  .page-title--compact-mobile p {
+    display: none;
+  }
+
+  .chat-sidebar {
+    padding-top: 14px;
+    padding-bottom: 12px;
+    background: rgba(255, 247, 239, 0.96);
+  }
+
+  .presence-list {
+    grid-auto-flow: column;
+    grid-auto-columns: minmax(180px, 78%);
+    overflow-x: auto;
+    padding-bottom: 4px;
+    scroll-snap-type: x proximity;
+  }
+
+  .presence-item {
+    min-height: 100%;
+    scroll-snap-align: start;
+  }
+
+  .chat-main {
+    min-height: auto;
+    grid-template-rows: auto minmax(0, 1fr) auto;
+    background: rgba(255, 250, 243, 0.84);
+  }
+
+  .chat-toolbar strong {
+    display: block;
+    font-size: 1.05rem;
+  }
+
+  .chat-toolbar__hint,
+  .chat-sidebar__hint {
+    display: none;
+  }
+
+  .chat-messages {
+    padding-top: 14px;
+    padding-bottom: 16px;
+    max-height: 44vh;
+    overflow: auto;
+  }
+
+  .message {
+    max-width: 100%;
+    padding: 12px 14px;
+    border-radius: 18px;
+  }
+
+  .composer-input-hint {
+    font-size: 0.86rem;
+    line-height: 1.5;
+  }
+
+  .composer-input--drop {
+    min-height: 64px;
+    border-radius: 18px;
+    resize: none;
+    padding: 14px 16px;
+  }
+
+  .composer-actions--stacked {
+    width: 100%;
+    height: auto;
+    grid-template-columns: minmax(0, 1fr) 92px;
+    grid-template-rows: none;
+    align-items: stretch;
+    gap: 10px;
+  }
+
+  .composer-actions--stacked .button {
+    min-height: 46px;
+    white-space: nowrap;
+  }
+
+  .chat-composer {
+    position: sticky;
+    bottom: 0;
+    background: rgba(251, 246, 239, 0.96);
+    backdrop-filter: blur(12px);
+    box-shadow: 0 -12px 30px rgba(49, 34, 22, 0.08);
+  }
+
+  .button {
+    width: 100%;
+  }
+
+  .hero__actions {
+    display: grid;
+  }
+
+  .home-sections {
+    gap: 16px;
+    padding-bottom: 28px;
+  }
+
+  .reader-paper {
+    padding: 22px 18px;
+    border-radius: 22px;
+  }
+
+  .site-brand__tag {
+    display: none;
+  }
+
+  .reader-mobile-bar--top {
+    grid-template-columns: 1fr 92px 92px;
+  }
+
+  .reader-mobile-bar__icon,
+  .reader-mobile-bar__action {
+    min-height: 42px;
+  }
+
+  .reader-catalog-mobile-sheet {
+    max-height: 78dvh;
+    padding: 16px 16px 24px;
+  }
+}

+ 24 - 0
app/layout.tsx

@@ -0,0 +1,24 @@
+import type { Metadata } from "next";
+import "./globals.css";
+import { SiteHeader } from "@/components/site-header";
+
+export const metadata: Metadata = {
+  title: "局域网书房",
+  description: "一个同时包含局域网聊天和书架阅读的网站骨架。"
+};
+
+export default function RootLayout({
+  children
+}: Readonly<{
+  children: React.ReactNode;
+}>) {
+  return (
+    <html lang="zh-CN">
+      <body>
+        <SiteHeader />
+        {children}
+      </body>
+    </html>
+  );
+}
+

+ 39 - 0
app/library/page.tsx

@@ -0,0 +1,39 @@
+import Link from "next/link";
+import { demoBooks } from "@/data/demo-books";
+
+export default function LibraryPage() {
+  return (
+    <main className="page-shell">
+      <section className="page-title">
+        <h1>书架</h1>
+        <p>现在书架里放了 4 本演示书,方便你直接测试书架布局、阅读页排版,以及从书架进入阅读器的整体体验。</p>
+      </section>
+
+      <section className="library-shell">
+        <div className="library-grid">
+          {demoBooks.map((book) => (
+            <article className="book-card" key={book.id}>
+              <div className="book-cover" style={{ ["--cover" as string]: book.coverStyle }}>
+                <div className="book-cover__chip">{book.category}</div>
+                <h2 className="book-cover__title">{book.title}</h2>
+                <p className="book-cover__author">{book.author}</p>
+              </div>
+
+              <div className="book-body">
+                <div className="chip-row">
+                  <span className="chip">{book.wordCount}</span>
+                  <span className="chip">{book.chapters.length} 章演示内容</span>
+                </div>
+                <p>{book.description}</p>
+                <Link className="button button--primary" href={`/reader/${book.id}`}>
+                  开始阅读
+                </Link>
+              </div>
+            </article>
+          ))}
+        </div>
+      </section>
+    </main>
+  );
+}
+

+ 104 - 0
app/page.tsx

@@ -0,0 +1,104 @@
+import Link from "next/link";
+
+const chatHighlights = [
+  "打开网站即自动生成临时设备身份",
+  "公共聊天室支持文字、图片、文件、粘贴和拖拽",
+  "手机和电脑共用同一个入口,便于局域网内快速传资料"
+];
+
+const libraryHighlights = [
+  "书架内置几本演示书,方便先验证阅读体验",
+  "阅读页采用干净居中的版心,弱干扰、强正文",
+  "后续可继续接入章节目录、阅读设置和进度同步"
+];
+
+const agentHighlights = [
+  "按成员卡片方式查看每个 agent 的角色、状态和当前任务",
+  "适合后续接入真实心跳、队列、产出摘要和运行节点信息",
+  "同样兼容手机查看,适合做局域网里的总览大盘"
+];
+
+export default function HomePage() {
+  return (
+    <main className="page-shell">
+      <section className="hero">
+        <div className="card hero__main">
+          <div className="eyebrow">一期骨架已就位</div>
+          <h1>一个入口,承接局域网聊天和小说阅读。</h1>
+          <p>
+            这个版本先把信息架构、页面风格和响应式骨架搭起来。你可以先从聊天室进入,也可以直接去书架页查看阅读器的版式方向。
+          </p>
+          <div className="hero__actions">
+            <Link className="button button--primary" href="/chat">
+              打开聊天室
+            </Link>
+            <Link className="button button--secondary" href="/library">
+              进入书架
+            </Link>
+          </div>
+        </div>
+
+        <div className="hero__aside">
+          <div className="stat-card">
+            <h3>局域网公共聊天室</h3>
+            <p>为几台电脑和手机之间传送文本、图片、文件而设计,页面结构已经按后续实时通信预留好了区域。</p>
+          </div>
+          <div className="stat-card">
+            <h3>简洁阅读器</h3>
+            <p>书架、章节、阅读页都已经连通,后续只需要继续接入真实数据和阅读设置即可。</p>
+          </div>
+        </div>
+      </section>
+
+      <section className="home-sections">
+        <div className="section-card">
+          <h2>模块一:聊天室</h2>
+          <p>先做一个你自己在局域网里使用的公共聊天空间,操作简单,文件流转顺手。</p>
+          <div className="section-card__list">
+            {chatHighlights.map((item, index) => (
+              <div className="section-card__item" key={item}>
+                <div className="section-card__index">{index + 1}</div>
+                <div>{item}</div>
+              </div>
+            ))}
+          </div>
+          <Link className="button button--primary" href="/chat">
+            去看聊天页骨架
+          </Link>
+        </div>
+
+        <div className="section-card">
+          <h2>模块二:书架与阅读</h2>
+          <p>阅读页先走你要的简洁路线,把版心、段落、操作区和手机排版先定下来。</p>
+          <div className="section-card__list">
+            {libraryHighlights.map((item, index) => (
+              <div className="section-card__item" key={item}>
+                <div className="section-card__index">{index + 1}</div>
+                <div>{item}</div>
+              </div>
+            ))}
+          </div>
+          <Link className="button button--secondary" href="/library">
+            去看书架骨架
+          </Link>
+        </div>
+
+        <div className="section-card">
+          <h2>模块三:Agent 观察页</h2>
+          <p>新增一个偏大盘视角的成员总览,主要用于观察各个 agent 当前的状态和最近输出。</p>
+          <div className="section-card__list">
+            {agentHighlights.map((item, index) => (
+              <div className="section-card__item" key={item}>
+                <div className="section-card__index">{index + 1}</div>
+                <div>{item}</div>
+              </div>
+            ))}
+          </div>
+          <Link className="button button--primary" href="/agents">
+            去看 Agent 页面
+          </Link>
+        </div>
+      </section>
+    </main>
+  );
+}

+ 41 - 0
app/reader/[bookId]/page.tsx

@@ -0,0 +1,41 @@
+import { notFound } from "next/navigation";
+import { ReaderView } from "@/components/reader/reader-view";
+import { getBookById } from "@/data/demo-books";
+
+type ReaderPageProps = {
+  params: Promise<{
+    bookId: string;
+  }>;
+  searchParams?: Promise<{
+    chapter?: string;
+    width?: string;
+    theme?: string;
+    font?: string;
+  }>;
+};
+
+export default async function ReaderPage({ params, searchParams }: ReaderPageProps) {
+  const { bookId } = await params;
+  const resolvedSearchParams = searchParams ? await searchParams : undefined;
+  const book = getBookById(bookId);
+
+  if (!book) {
+    notFound();
+  }
+
+  const rawChapterIndex = Number(resolvedSearchParams?.chapter ?? "0");
+  const chapterIndex =
+    Number.isFinite(rawChapterIndex) && rawChapterIndex >= 0 && rawChapterIndex < book.chapters.length
+      ? rawChapterIndex
+      : 0;
+
+  return (
+    <ReaderView
+      book={book}
+      chapterIndex={chapterIndex}
+      initialWidthKey={resolvedSearchParams?.width}
+      initialThemeKey={resolvedSearchParams?.theme}
+      initialFontKey={resolvedSearchParams?.font}
+    />
+  );
+}

+ 33 - 0
components/agents/agent-avatar.tsx

@@ -0,0 +1,33 @@
+import { AgentAvatarKind } from "@/types/agent";
+
+type AgentAvatarProps = {
+  kind: AgentAvatarKind;
+  label: string;
+  large?: boolean;
+};
+
+export function AgentAvatar({ kind, label, large = false }: AgentAvatarProps) {
+  return (
+    <div
+      className={`agent-avatar agent-avatar--${kind}${large ? " agent-avatar--large" : ""}`}
+      aria-label={label}
+      title={label}
+    >
+      <div className="agent-avatar__hair" />
+      <div className="agent-avatar__head">
+        <div className="agent-avatar__brows">
+          <span />
+          <span />
+        </div>
+        <div className="agent-avatar__eyes">
+          <span />
+          <span />
+        </div>
+        <div className="agent-avatar__nose" />
+        <div className="agent-avatar__mouth" />
+      </div>
+      <div className="agent-avatar__body" />
+      <div className="agent-avatar__accent" />
+    </div>
+  );
+}

+ 162 - 0
components/agents/agents-dashboard.tsx

@@ -0,0 +1,162 @@
+"use client";
+
+import Link from "next/link";
+import { startTransition, useEffect, useState } from "react";
+import { AgentAvatar } from "@/components/agents/agent-avatar";
+import { agentStatusFilters } from "@/data/demo-agents";
+import { AgentFeed, AgentStatus } from "@/types/agent";
+
+const statusClassMap = {
+  working: "status-pill status-pill--working",
+  idle: "status-pill status-pill--idle",
+  warning: "status-pill status-pill--warning",
+  offline: "status-pill status-pill--offline"
+} as const;
+
+type AgentsDashboardProps = {
+  initialFeed: AgentFeed;
+  initialStatus: "all" | AgentStatus;
+};
+
+function isAgentStatus(value: string): value is AgentStatus {
+  return value === "working" || value === "idle" || value === "warning" || value === "offline";
+}
+
+function formatTimeLabel(value: string) {
+  return new Date(value).toLocaleTimeString("zh-CN", {
+    hour: "2-digit",
+    minute: "2-digit",
+    second: "2-digit"
+  });
+}
+
+export function AgentsDashboard({ initialFeed, initialStatus }: AgentsDashboardProps) {
+  const [feed, setFeed] = useState(initialFeed);
+  const [activeStatus, setActiveStatus] = useState<"all" | AgentStatus>(initialStatus);
+
+  useEffect(() => {
+    let mounted = true;
+
+    async function loadAgents() {
+      const query = activeStatus === "all" ? "" : `?status=${activeStatus}`;
+      const response = await fetch(`/api/agents${query}`, { cache: "no-store" });
+
+      if (!response.ok) {
+        throw new Error("Failed to load agents");
+      }
+
+      const data = (await response.json()) as AgentFeed;
+
+      if (!mounted) {
+        return;
+      }
+
+      startTransition(() => {
+        setFeed(data);
+      });
+    }
+
+    loadAgents().catch(() => undefined);
+    const interval = window.setInterval(() => {
+      loadAgents().catch(() => undefined);
+    }, 10000);
+
+    return () => {
+      mounted = false;
+      window.clearInterval(interval);
+    };
+  }, [activeStatus]);
+
+  const agents = feed.agents;
+  const counts = {
+    working: agents.filter((agent) => agent.status === "working").length,
+    idle: agents.filter((agent) => agent.status === "idle").length,
+    warning: agents.filter((agent) => agent.status === "warning").length,
+    offline: agents.filter((agent) => agent.status === "offline").length
+  };
+
+  return (
+    <section className="agents-shell agents-shell--compact">
+      <div className="agents-monitor-top">
+        <div className="agents-monitor-source">
+          <strong>{feed.source === "file" ? "已接入真实数据源" : "当前使用演示数据"}</strong>
+          <span>
+            来源 {feed.sourceLabel} · 最近刷新 {formatTimeLabel(feed.fetchedAt)}
+          </span>
+        </div>
+
+        <div className="chip-row">
+          {agentStatusFilters.map((filter) => (
+            <button
+              key={filter.key}
+              type="button"
+              className={`filter-chip${activeStatus === filter.key ? " filter-chip--active" : ""}`}
+              onClick={() => {
+                if (filter.key === "all" || isAgentStatus(filter.key)) {
+                  setActiveStatus(filter.key);
+                }
+              }}
+            >
+              {filter.label}
+            </button>
+          ))}
+        </div>
+      </div>
+
+      <div className="agents-monitor-strip">
+        <div className="agents-monitor-strip__item">工作中 {counts.working}</div>
+        <div className="agents-monitor-strip__item">待命 {counts.idle}</div>
+        <div className="agents-monitor-strip__item">待确认 {counts.warning}</div>
+        <div className="agents-monitor-strip__item">离线 {counts.offline}</div>
+        <div className="agents-monitor-strip__item">总计 {agents.length}</div>
+      </div>
+
+      <div className="agents-worklist">
+        {agents.map((agent) => (
+          <Link className="agents-worklist__link" href={`/agents/${agent.id}`} key={agent.id}>
+            <article className="agents-workitem">
+              <div className="agents-workitem__head">
+                <div className="agents-workitem__identity">
+                  <AgentAvatar kind={agent.avatarKind} label={agent.name} />
+                  <div>
+                    <div className="agents-workitem__name-row">
+                      <strong>{agent.name}</strong>
+                      <span className={statusClassMap[agent.status]}>{agent.statusLabel}</span>
+                    </div>
+                    <div className="agents-workitem__role">{agent.role}</div>
+                  </div>
+                </div>
+
+                <div className="agents-workitem__metrics">
+                  <span>心跳 {agent.lastHeartbeat}</span>
+                  <span>队列 {agent.queueDepth}</span>
+                  <span>今日完成 {agent.todayCompleted}</span>
+                </div>
+              </div>
+
+              <div className="agents-workitem__task">
+                <div className="agents-workitem__label">当前任务</div>
+                <div className="agents-workitem__value">{agent.currentTask}</div>
+              </div>
+
+              <div className="agents-workitem__meta">
+                <span>任务ID {agent.taskId}</span>
+                <span>阶段 {agent.taskStage}</span>
+                <span>主机 {agent.host}</span>
+                <span>Owner {agent.owner}</span>
+                <span>运行时长 {agent.uptime}</span>
+                <span>更新时间 {agent.updatedAt}</span>
+              </div>
+
+              <div className="agents-workitem__output">
+                <div className="agents-workitem__label">最近输出</div>
+                <div className="agents-workitem__value">{agent.lastOutput}</div>
+                {agent.lastError ? <div className="agents-workitem__error">异常: {agent.lastError}</div> : null}
+              </div>
+            </article>
+          </Link>
+        ))}
+      </div>
+    </section>
+  );
+}

+ 281 - 0
components/reader/reader-view.tsx

@@ -0,0 +1,281 @@
+"use client";
+
+import Link from "next/link";
+import { CSSProperties, useEffect, useMemo, useRef, useState } from "react";
+import { Book } from "@/types/book";
+
+type ReaderViewProps = {
+  book: Book;
+  chapterIndex: number;
+  initialThemeKey?: string;
+  initialFontKey?: string;
+  initialWidthKey?: string;
+};
+
+const themeOptions = [
+  { key: "warm", label: "暖米", page: "#f7f0e4", stage: "#d8d0c4", text: "#2b241e", catalog: "#e6ddd0" },
+  { key: "mist", label: "浅灰", page: "#efede7", stage: "#d6d4cf", text: "#2a2723", catalog: "#dfddd7" },
+  { key: "sepia", label: "茶棕", page: "#eee0cf", stage: "#d4c3b1", text: "#35281f", catalog: "#dfcdbb" }
+] as const;
+
+const fontOptions = [
+  { key: "sm", label: "小", size: "1.02rem", lineHeight: 2.05 },
+  { key: "md", label: "中", size: "1.18rem", lineHeight: 2.28 },
+  { key: "lg", label: "大", size: "1.34rem", lineHeight: 2.55 }
+] as const;
+
+const widthOptions = [
+  { key: "narrow", label: "窄", width: 760 },
+  { key: "medium", label: "中", width: 920 },
+  { key: "wide", label: "宽", width: 1080 }
+] as const;
+
+export function ReaderView({
+  book,
+  chapterIndex,
+  initialThemeKey,
+  initialFontKey,
+  initialWidthKey
+}: ReaderViewProps) {
+  const initialThemeIndex = Math.max(0, themeOptions.findIndex((item) => item.key === initialThemeKey));
+  const initialFontIndex = Math.max(0, fontOptions.findIndex((item) => item.key === initialFontKey));
+  const initialWidthIndex = Math.max(0, widthOptions.findIndex((item) => item.key === initialWidthKey));
+
+  const [themeIndex, setThemeIndex] = useState(initialThemeKey ? initialThemeIndex : 0);
+  const [fontIndex, setFontIndex] = useState(initialFontKey ? initialFontIndex : 1);
+  const [widthIndex, setWidthIndex] = useState(initialWidthKey ? initialWidthIndex : 1);
+  const [catalogOpen, setCatalogOpen] = useState(false);
+  const [progress, setProgress] = useState(0);
+  const stageRef = useRef<HTMLElement | null>(null);
+
+  useEffect(() => {
+    const pageColor = themeOptions[themeIndex].page;
+    document.body.classList.add("reader-body");
+    const previousBodyBackground = document.body.style.background;
+    const previousHtmlBackground = document.documentElement.style.background;
+
+    document.body.style.background = pageColor;
+    document.documentElement.style.background = pageColor;
+
+    return () => {
+      document.body.classList.remove("reader-body");
+      document.body.style.background = previousBodyBackground;
+      document.documentElement.style.background = previousHtmlBackground;
+    };
+  }, [themeIndex]);
+
+  useEffect(() => {
+    const stage = stageRef.current;
+    if (!stage) {
+      return;
+    }
+
+    stage.scrollTo({ top: 0, behavior: "auto" });
+    setCatalogOpen(false);
+
+    const updateProgress = () => {
+      const maxScroll = stage.scrollHeight - stage.clientHeight;
+      const nextProgress = maxScroll <= 0 ? 0 : (stage.scrollTop / maxScroll) * 100;
+      setProgress(nextProgress);
+    };
+
+    updateProgress();
+    stage.addEventListener("scroll", updateProgress);
+    window.addEventListener("resize", updateProgress);
+
+    return () => {
+      stage.removeEventListener("scroll", updateProgress);
+      window.removeEventListener("resize", updateProgress);
+    };
+  }, [chapterIndex, widthIndex, fontIndex, themeIndex]);
+
+  const chapter = book.chapters[chapterIndex];
+  const prevChapterIndex = chapterIndex > 0 ? chapterIndex - 1 : null;
+  const nextChapterIndex = chapterIndex < book.chapters.length - 1 ? chapterIndex + 1 : null;
+  const currentTheme = themeOptions[themeIndex];
+  const currentFont = fontOptions[fontIndex];
+  const currentWidth = widthOptions[widthIndex];
+  const chapterWords = chapter.content.join("").length;
+
+  const shellWidthStyle = useMemo(
+    () =>
+      ({
+        width: "var(--reader-shell-width)"
+      }) as CSSProperties,
+    []
+  );
+
+  const cycleFont = () => setFontIndex((value) => (value + 1) % fontOptions.length);
+  const cycleTheme = () => setThemeIndex((value) => (value + 1) % themeOptions.length);
+  const cycleWidth = () => setWidthIndex((value) => (value + 1) % widthOptions.length);
+
+  const catalogContent = (
+    <>
+      <div className="reader-catalog__header">
+        <h2>目录</h2>
+        <button type="button" className="reader-catalog__close" onClick={() => setCatalogOpen(false)}>
+          关闭
+        </button>
+      </div>
+
+      <div className="reader-catalog__grid">
+        {book.chapters.map((item, index) => (
+          <Link
+            key={item.id}
+            href={`/reader/${book.id}?chapter=${index}`}
+            className={`reader-catalog__item${index === chapterIndex ? " reader-catalog__item--active" : ""}`}
+            onClick={() => setCatalogOpen(false)}
+          >
+            <span>第 {index + 1} 章</span>
+            <strong>{item.title}</strong>
+          </Link>
+        ))}
+      </div>
+    </>
+  );
+
+  return (
+    <main
+      className="reader-stage"
+      style={
+        {
+          background: currentTheme.stage,
+          "--reader-page": currentTheme.page,
+          "--reader-shell-width": `min(${currentWidth.width}px, calc(100vw - 280px))`
+        } as CSSProperties
+      }
+      ref={stageRef}
+    >
+      <div className="reader-progress-rail" aria-hidden="true">
+        <div className="reader-progress-rail__track">
+          <div className="reader-progress-rail__fill" style={{ height: `${progress}%` }} />
+        </div>
+      </div>
+
+      <div className="reader-mobile-bar reader-mobile-bar--top" style={{ background: currentTheme.page }}>
+        <Link className="reader-mobile-bar__icon reader-mobile-bar__icon--back" href="/library" aria-label="返回书架">
+          返回
+        </Link>
+        <button className="reader-mobile-bar__icon" type="button" onClick={cycleTheme} aria-label="切换主题">
+          主题 {currentTheme.label}
+        </button>
+        <button className="reader-mobile-bar__icon" type="button" onClick={cycleFont} aria-label="切换字体">
+          字体 {currentFont.label}
+        </button>
+      </div>
+
+      <div className="reader-mobile-bar reader-mobile-bar--bottom" style={{ background: currentTheme.page }}>
+        <button className="reader-mobile-bar__action" type="button" onClick={() => setCatalogOpen(true)}>
+          目录
+        </button>
+
+        {prevChapterIndex !== null ? (
+          <Link className="reader-mobile-bar__action" href={`/reader/${book.id}?chapter=${prevChapterIndex}`}>
+            上一章
+          </Link>
+        ) : (
+          <span className="reader-mobile-bar__action reader-mobile-bar__action--disabled">上一章</span>
+        )}
+
+        {nextChapterIndex !== null ? (
+          <Link className="reader-mobile-bar__action" href={`/reader/${book.id}?chapter=${nextChapterIndex}`}>
+            下一章
+          </Link>
+        ) : (
+          <span className="reader-mobile-bar__action reader-mobile-bar__action--disabled">下一章</span>
+        )}
+      </div>
+
+      <div className="reader-desktop-layout">
+        <div className="reader-float reader-float--left" aria-label="阅读左侧工具">
+          <Link className="reader-float__button" href="/library">
+            <strong>返回</strong>
+            <span>回到书架</span>
+          </Link>
+          <button className="reader-float__button" type="button" onClick={() => setCatalogOpen(true)}>
+            <strong>目录</strong>
+            <span>弹出目录</span>
+          </button>
+        </div>
+
+        <section className="reader-qq-shell" style={shellWidthStyle}>
+          <div className="reader-catalog-mobile-mask" hidden={!catalogOpen} onClick={() => setCatalogOpen(false)} />
+
+          <div
+            className={`reader-catalog-mobile-sheet${catalogOpen ? " is-open" : ""}`}
+            style={{ background: currentTheme.catalog }}
+            aria-hidden={!catalogOpen}
+          >
+            {catalogContent}
+          </div>
+
+          {catalogOpen ? (
+            <div className="reader-catalog-inline" style={{ background: currentTheme.catalog }}>
+              {catalogContent}
+            </div>
+          ) : null}
+
+          <div
+            className={`reader-qq-paper${catalogOpen ? " reader-qq-paper--desktop-hidden" : ""}`}
+            style={{ background: currentTheme.page }}
+          >
+            <header className="reader-qq-header">
+              <h1>{chapter.title}</h1>
+              <div className="reader-qq-meta">
+                <span>书名:{book.title}</span>
+                <span>作者:{book.author}</span>
+                <span>本章字数:{chapterWords} 字</span>
+                <span>总字数:{book.wordCount}</span>
+              </div>
+            </header>
+
+            <article className="reader-qq-content" style={{ color: currentTheme.text }}>
+              {chapter.content.map((paragraph) => (
+                <p key={paragraph} style={{ fontSize: currentFont.size, lineHeight: currentFont.lineHeight }}>
+                  {paragraph}
+                </p>
+              ))}
+            </article>
+
+            <footer className="reader-qq-footer">
+              <button className="reader-qq-footer__ghost" type="button" onClick={() => setCatalogOpen(true)}>
+                目录
+              </button>
+
+              {prevChapterIndex !== null ? (
+                <Link className="reader-qq-footer__button" href={`/reader/${book.id}?chapter=${prevChapterIndex}`}>
+                  上一章
+                </Link>
+              ) : (
+                <span className="reader-qq-footer__button reader-qq-footer__button--disabled">已是第一章</span>
+              )}
+
+              {nextChapterIndex !== null ? (
+                <Link className="reader-qq-footer__button" href={`/reader/${book.id}?chapter=${nextChapterIndex}`}>
+                  下一章
+                </Link>
+              ) : (
+                <span className="reader-qq-footer__button reader-qq-footer__button--disabled">已是最后一章</span>
+              )}
+            </footer>
+          </div>
+        </section>
+
+        <div className="reader-float reader-float--right" aria-label="阅读右侧工具">
+          <button className="reader-float__button" type="button" onClick={cycleFont}>
+            <strong>字号</strong>
+            <span>当前{currentFont.label}</span>
+          </button>
+          <button className="reader-float__button" type="button" onClick={cycleTheme}>
+            <strong>主题</strong>
+            <span>当前{currentTheme.label}</span>
+          </button>
+          <button className="reader-float__button" type="button" onClick={cycleWidth}>
+            <strong>版心</strong>
+            <span>当前{currentWidth.label}</span>
+          </button>
+        </div>
+      </div>
+    </main>
+  );
+}

+ 108 - 0
components/site-header.tsx

@@ -0,0 +1,108 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { useEffect, useState } from "react";
+
+const navItems = [
+  { href: "/", label: "首页" },
+  { href: "/chat", label: "聊天室" },
+  { href: "/library", label: "书架" },
+  { href: "/agents", label: "Agent 观察" }
+];
+
+export function SiteHeader() {
+  const pathname = usePathname();
+  const [menuOpen, setMenuOpen] = useState(false);
+
+  useEffect(() => {
+    setMenuOpen(false);
+  }, [pathname]);
+
+  useEffect(() => {
+    const onKeyDown = (event: KeyboardEvent) => {
+      if (event.key === "Escape") {
+        setMenuOpen(false);
+      }
+    };
+
+    window.addEventListener("keydown", onKeyDown);
+    return () => window.removeEventListener("keydown", onKeyDown);
+  }, []);
+
+  if (pathname?.startsWith("/reader/")) {
+    return null;
+  }
+
+  return (
+    <header className={`site-header${menuOpen ? " site-header--menu-open" : ""}`}>
+      <div className="page-shell site-header__inner">
+        <Link href="/" className="site-brand">
+          <div className="site-brand__mark">LAN</div>
+          <div>
+            <div className="site-brand__name">局域网书房</div>
+            <div className="site-brand__tag">聊天、传文件、看小说,放在一个入口里</div>
+          </div>
+        </Link>
+
+        <nav className="site-nav" aria-label="主导航">
+          {navItems.map((item) => (
+            <Link key={item.href} href={item.href} className={pathname === item.href ? "is-active" : undefined}>
+              {item.label}
+            </Link>
+          ))}
+        </nav>
+
+        <button
+          type="button"
+          className={`site-menu-button${menuOpen ? " is-open" : ""}`}
+          aria-expanded={menuOpen}
+          aria-controls="mobile-site-nav"
+          aria-label={menuOpen ? "关闭导航" : "打开导航"}
+          onClick={() => setMenuOpen((value) => !value)}
+        >
+          <span />
+          <span />
+          <span />
+        </button>
+      </div>
+
+      <div
+        className={`site-drawer-backdrop${menuOpen ? " is-open" : ""}`}
+        onClick={() => setMenuOpen(false)}
+        aria-hidden={!menuOpen}
+      />
+
+      <div className={`site-drawer${menuOpen ? " is-open" : ""}`} id="mobile-site-nav" aria-hidden={!menuOpen}>
+        <div className="site-drawer__panel">
+          <div className="site-drawer__top">
+            <div>
+              <div className="site-drawer__title">导航</div>
+              <div className="site-drawer__hint">手机端单独唤出,和页面正文彻底分层</div>
+            </div>
+            <button
+              type="button"
+              className="site-drawer__close"
+              aria-label="关闭导航"
+              onClick={() => setMenuOpen(false)}
+            >
+              关闭
+            </button>
+          </div>
+
+          <nav className="site-drawer__nav" aria-label="移动端主导航">
+            {navItems.map((item) => (
+              <Link
+                key={item.href}
+                href={item.href}
+                className={pathname === item.href ? "is-active" : undefined}
+              >
+                {item.label}
+              </Link>
+            ))}
+          </nav>
+        </div>
+      </div>
+    </header>
+  );
+}

+ 138 - 0
data/demo-agents.ts

@@ -0,0 +1,138 @@
+import { AgentProfile, AgentStatus } from "@/types/agent";
+
+export const demoAgents: AgentProfile[] = [
+  {
+    id: "main-orchestrator",
+    name: "main",
+    role: "主调度 Agent",
+    avatarKind: "male-dispatcher",
+    status: "working",
+    statusLabel: "工作中",
+    host: "macbook-ops.local",
+    owner: "OpenClaw Core",
+    currentTask: "拆分今日任务并下发给 news-intel、ops-updater、research-assistant",
+    taskId: "orch-20260327-0841",
+    taskStage: "dispatching",
+    queueDepth: 3,
+    todayCompleted: 18,
+    uptime: "12天 4小时",
+    lastHeartbeat: "2秒前",
+    updatedAt: "刚刚",
+    lastOutput: "已完成一轮任务编排,正在等待下游 agent 回传结果。",
+    tags: ["调度", "编排", "主控"]
+  },
+  {
+    id: "news-intel",
+    name: "news-intel",
+    role: "情报汇总 Agent",
+    avatarKind: "female-analyst",
+    status: "working",
+    statusLabel: "工作中",
+    host: "macbook-ops.local",
+    owner: "OpenClaw Intel",
+    currentTask: "整理今天的资讯摘要与来源,并准备发送给 main",
+    taskId: "intel-20260327-0915",
+    taskStage: "summarizing",
+    queueDepth: 2,
+    todayCompleted: 9,
+    uptime: "18天 9小时",
+    lastHeartbeat: "5秒前",
+    updatedAt: "1分钟前",
+    lastOutput: "最近一次摘要已经生成,等待主调度确认是否继续扩展来源。",
+    tags: ["情报", "摘要", "采集"]
+  },
+  {
+    id: "ops-updater",
+    name: "ops-updater",
+    role: "运维巡检 Agent",
+    avatarKind: "male-ops",
+    status: "working",
+    statusLabel: "工作中",
+    host: "macbook-ops.local",
+    owner: "OpenClaw Infra",
+    currentTask: "执行 openclaw-daily-security-check 与服务可用性巡检",
+    taskId: "ops-20260327-0932",
+    taskStage: "checking",
+    queueDepth: 1,
+    todayCompleted: 14,
+    uptime: "22天 1小时",
+    lastHeartbeat: "8秒前",
+    updatedAt: "2分钟前",
+    lastOutput: "巡检中记录到 1 条待人工确认告警,尚未升级为阻断事件。",
+    tags: ["安全", "巡检", "更新"]
+  },
+  {
+    id: "research-assistant",
+    name: "research-assistant",
+    role: "研究支持 Agent",
+    avatarKind: "female-researcher",
+    status: "idle",
+    statusLabel: "待命",
+    host: "macbook-ops.local",
+    owner: "OpenClaw Research",
+    currentTask: "等待新的研究主题进入任务队列",
+    taskId: "idle",
+    taskStage: "ready",
+    queueDepth: 0,
+    todayCompleted: 6,
+    uptime: "5天 7小时",
+    lastHeartbeat: "18秒前",
+    updatedAt: "6分钟前",
+    lastOutput: "上一条输出是资料整理清单,已同步到共享上下文。",
+    tags: ["研究", "整理", "支持"]
+  },
+  {
+    id: "observer-panel",
+    name: "observer-panel",
+    role: "状态观察 Agent",
+    avatarKind: "female-observer",
+    status: "warning",
+    statusLabel: "待确认",
+    host: "macbook-ops.local",
+    owner: "OpenClaw Observe",
+    currentTask: "等待人工确认一条心跳异常告警",
+    taskId: "obs-20260327-0745",
+    taskStage: "waiting_ack",
+    queueDepth: 1,
+    todayCompleted: 11,
+    uptime: "9天 2小时",
+    lastHeartbeat: "41秒前",
+    updatedAt: "9分钟前",
+    lastOutput: "检测到一个子任务心跳偏长,建议检查是否需要重试。",
+    lastError: "heartbeat_delay_warning",
+    tags: ["监控", "观察", "告警"]
+  },
+  {
+    id: "session-maintainer",
+    name: "session-maintainer",
+    role: "会话维护 Agent",
+    avatarKind: "male-maintainer",
+    status: "offline",
+    statusLabel: "离线",
+    host: "macbook-ops.local",
+    owner: "OpenClaw Session",
+    currentTask: "当前未接入状态总线",
+    taskId: "offline",
+    taskStage: "disconnected",
+    queueDepth: 0,
+    todayCompleted: 0,
+    uptime: "0天 0小时",
+    lastHeartbeat: "14小时前",
+    updatedAt: "昨天",
+    lastOutput: "最后一次上报发生在昨晚 22:17,之后未再回传会话状态。",
+    lastError: "agent_offline",
+    tags: ["会话", "心跳", "维护"]
+  }
+];
+
+export const agentStatusFilters: Array<{ key: "all" | AgentStatus; label: string }> = [
+  { key: "all", label: "全部" },
+  { key: "working", label: "工作中" },
+  { key: "idle", label: "待命" },
+  { key: "warning", label: "待确认" },
+  { key: "offline", label: "离线" }
+];
+
+export function getAgentById(agentId: string) {
+  return demoAgents.find((agent) => agent.id === agentId);
+}

+ 106 - 0
data/demo-books.ts

@@ -0,0 +1,106 @@
+import { Book } from "@/types/book";
+
+export const demoBooks: Book[] = [
+  {
+    id: "lan-archive",
+    title: "局域网夜航",
+    author: "程未远",
+    category: "近未来",
+    description:
+      "一座封闭园区、几台常亮的旧电脑,以及深夜里不断跳出的陌生消息。故事从一个内部聊天室开始,也从那里逐渐偏离日常。",
+    wordCount: "12.8 万字",
+    coverStyle: "linear-gradient(135deg, #b86143, #5c2d23)",
+    chapters: [
+      {
+        id: "chapter-1",
+        title: "第一章 机房尽头的灯",
+        content: [
+          "凌晨两点十三分,园区办公楼里只剩下二层最靠里的机房还亮着灯。陈泊把最后一台旧主机的风扇罩扣回去,抬头时,玻璃门上映出他略显疲惫的脸。",
+          "楼道里很安静,安静得能听见交换机持续不断的细密电流声。那声音像潮水一样贴着墙面蔓延,穿过半开的门缝,也穿过他刚刚搭起来的局域网聊天页。",
+          "页面很简单,甚至称得上粗糙。左边是在线设备,右边是聊天消息,底部一个输入框,可以发文字,也可以拖图片和文件进去。原本它只是为了方便他在几台电脑之间互传资料。",
+          "可就在他准备关机的时候,聊天室里跳出了一条新消息。发送者是一台他从未见过的设备,名字显示为“Unknown-Edge-1.27-H3M2”。",
+          "那条消息只有八个字:你终于把门打开了。",
+          "陈泊盯着屏幕,手指停在键盘上方,迟迟没有落下。他确认过,这个局域网没有接外网,也没有其他人知道访问地址。可消息提示音仍旧在房间里轻轻回荡,像是谁站在走廊深处,耐心地敲了第二次门。"
+        ]
+      },
+      {
+        id: "chapter-2",
+        title: "第二章 没有登记的设备",
+        content: [
+          "他先检查 DHCP 记录,又翻了交换机最近一小时的地址分配表,结果一片空白。那台设备没有登记,没有接入记录,也没有任何真实存在的迹象。",
+          "可聊天页里的头像气泡还停在那里,像一粒不合时宜的灰尘。陈泊尝试发出一句“你是谁”,对方却在三秒后回了一张图片。",
+          "图片内容是这间机房。准确地说,是五分钟前的机房。画面里他正背对镜头,弯腰整理电源线,而取景角度来自房间最深处、那排早就废弃的机柜之间。",
+          "他猛地转身,那里只有黑暗,和一排没有通电的指示灯。"
+        ]
+      }
+    ]
+  },
+  {
+    id: "tea-house",
+    title: "旧书铺茶事",
+    author: "沈南絮",
+    category: "日常",
+    description:
+      "一间开在老街口的旧书铺,白天卖书,傍晚煮茶。每位进门的客人都带来一本书的故事,也带走一点安静。",
+    wordCount: "8.4 万字",
+    coverStyle: "linear-gradient(135deg, #8f6a3d, #314438)",
+    chapters: [
+      {
+        id: "chapter-1",
+        title: "第一章 雨落在旧街口",
+        content: [
+          "傍晚五点,雨刚刚落下来,青石板路被洗得发亮。书铺门口挂着一盏老式铜灯,灯光穿过潮润的空气,把门前那块“今日新到旧书”的小黑板照得有些发暖。",
+          "许青辞把最后一箱旧书搬进门,抬手拂去肩上的水珠。门口风铃轻轻一响,有位撑着深蓝长伞的姑娘走了进来,鞋尖落在木地板上,带来一点很轻的湿意。",
+          "她没有立刻说话,只在靠窗的架子前停住,像在辨认一本已经很久没见过的旧书。"
+        ]
+      }
+    ]
+  },
+  {
+    id: "star-mail",
+    title: "星港来信",
+    author: "林观海",
+    category: "科幻",
+    description:
+      "远航舰队返程之前,地球收到一封延迟了十九年的星际邮件。它改变了一个港口城市,也改变了一群等待的人。",
+    wordCount: "15.1 万字",
+    coverStyle: "linear-gradient(135deg, #355c7d, #121c2e)",
+    chapters: [
+      {
+        id: "chapter-1",
+        title: "第一章 邮件抵达前夜",
+        content: [
+          "海港城的夜总是比内陆亮一些。悬在码头上方的轨道灯像一排静止的流星,把泊位、塔吊和远处沉默的深空接收站一起照成银白色。",
+          "路昭从监测塔的楼梯上走下来时,终端忽然震了一下。屏幕上只有一句系统提示:深空民用信道存在待解包数据,请立即确认。",
+          "他看着那行字,脚步不由自主地慢了半拍。因为那条信道已经沉寂了十九年,久到所有人都默认它再也不会亮起。"
+        ]
+      }
+    ]
+  },
+  {
+    id: "north-city",
+    title: "北城旧事录",
+    author: "顾行舟",
+    category: "都市",
+    description:
+      "一座旧城的黄昏、几条重复经过的街道、以及那些看似平常却慢慢累积出重量的日子。",
+    wordCount: "10.2 万字",
+    coverStyle: "linear-gradient(135deg, #7c5a43, #28384f)",
+    chapters: [
+      {
+        id: "chapter-1",
+        title: "第一章 天桥下的晚风",
+        content: [
+          "晚高峰刚过,天桥下的人群还没散完。霓虹广告在灰蓝色的天幕里逐渐亮起来,把路口照得像刚从雨里捞出来一样湿润。",
+          "周临川站在报刊亭旁边,手里拿着一杯已经不热的豆浆,抬头看见远处那座旧商场外墙上的灯牌又坏了一角。",
+          "北城从来不擅长把一切都修得整整齐齐,可也正因如此,很多细小的故事才有了藏身的缝隙。"
+        ]
+      }
+    ]
+  }
+];
+
+export function getBookById(bookId: string) {
+  return demoBooks.find((book) => book.id === bookId);
+}
+

+ 30 - 0
deploy/macos/com.lan-reader-chat.agentfeed.plist

@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+  <key>Label</key>
+  <string>com.lan-reader-chat.agentfeed</string>
+
+  <key>ProgramArguments</key>
+  <array>
+    <string>/bin/zsh</string>
+    <string>-lc</string>
+    <string>cd /Users/ops/lan-reader-chat &amp;&amp; node scripts/sync-openclaw-agent-feed.mjs /Users/ops/openclaw-runtime/openclaw-agents.json /Users/ops/lan-reader-chat/storage/agents/openclaw-agents.json</string>
+  </array>
+
+  <key>WorkingDirectory</key>
+  <string>/Users/ops/lan-reader-chat</string>
+
+  <key>RunAtLoad</key>
+  <true/>
+
+  <key>StartInterval</key>
+  <integer>30</integer>
+
+  <key>StandardOutPath</key>
+  <string>/Users/ops/lan-reader-chat/logs/agentfeed.out.log</string>
+
+  <key>StandardErrorPath</key>
+  <string>/Users/ops/lan-reader-chat/logs/agentfeed.err.log</string>
+</dict>
+</plist>

+ 38 - 0
deploy/macos/com.lan-reader-chat.web.plist

@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+  <key>Label</key>
+  <string>com.lan-reader-chat.web</string>
+
+  <key>ProgramArguments</key>
+  <array>
+    <string>/bin/zsh</string>
+    <string>-lc</string>
+    <string>cd /Users/ops/lan-reader-chat &amp;&amp; npm run start -- --hostname 0.0.0.0 --port 3000</string>
+  </array>
+
+  <key>WorkingDirectory</key>
+  <string>/Users/ops/lan-reader-chat</string>
+
+  <key>RunAtLoad</key>
+  <true/>
+
+  <key>KeepAlive</key>
+  <true/>
+
+  <key>StandardOutPath</key>
+  <string>/Users/ops/lan-reader-chat/logs/web.out.log</string>
+
+  <key>StandardErrorPath</key>
+  <string>/Users/ops/lan-reader-chat/logs/web.err.log</string>
+
+  <key>EnvironmentVariables</key>
+  <dict>
+    <key>NODE_ENV</key>
+    <string>production</string>
+    <key>OPENCLAW_AGENT_FEED_PATH</key>
+    <string>/Users/ops/lan-reader-chat/storage/agents/openclaw-agents.json</string>
+  </dict>
+</dict>
+</plist>

+ 193 - 0
docs/macbook-deployment.md

@@ -0,0 +1,193 @@
+# MacBook 局域网部署流程
+
+本文档用于把当前网站部署到一台局域网内的 MacBook 上,供公司内部用户访问。
+
+## 一、推荐部署目录
+
+建议在 MacBook 上使用固定目录:
+
+```text
+/Users/ops/lan-reader-chat
+```
+
+目录约定:
+
+```text
+/Users/ops/lan-reader-chat
+├─ app/
+├─ components/
+├─ data/
+├─ docs/
+├─ lib/
+├─ scripts/
+├─ storage/
+│  ├─ agents/
+│  ├─ chat-uploads/
+│  └─ chat.sqlite
+├─ .next/
+├─ package.json
+└─ next.config.ts
+```
+
+说明:
+
+- `storage/chat.sqlite`:聊天记录数据库
+- `storage/chat-uploads/`:图片和文件上传目录
+- `storage/agents/openclaw-agents.json`:OpenClaw agent 状态文件
+
+## 二、基础环境
+
+MacBook 上建议准备:
+
+1. `Node.js LTS`
+2. `npm`
+3. 一个专门的运行用户,例如 `ops`
+
+建议先确认:
+
+```bash
+node -v
+npm -v
+```
+
+## 三、首次部署
+
+1. 把项目放到目标目录:
+
+```bash
+cd /Users/ops
+git clone <your-repo> lan-reader-chat
+cd /Users/ops/lan-reader-chat
+```
+
+2. 安装依赖:
+
+```bash
+npm install
+```
+
+3. 创建存储目录:
+
+```bash
+mkdir -p storage/agents
+mkdir -p storage/chat-uploads
+```
+
+4. 首次构建:
+
+```bash
+npm run build
+```
+
+5. 本机验证:
+
+```bash
+npm run start -- --hostname 0.0.0.0 --port 3000
+```
+
+浏览器访问:
+
+```text
+http://localhost:3000
+```
+
+局域网其他设备访问:
+
+```text
+http://MacBook局域网IP:3000
+```
+
+## 四、OpenClaw agent 状态接入
+
+网站默认读取:
+
+```text
+storage/agents/openclaw-agents.json
+```
+
+如果 OpenClaw 的输出文件不在这个路径,可以通过环境变量指定:
+
+```bash
+OPENCLAW_AGENT_FEED_PATH=/some/path/openclaw-agents.json
+```
+
+推荐方式:
+
+- OpenClaw 或 ops agent 定时生成 JSON
+- 用项目内的同步脚本落到网站目录
+
+示例:
+
+```bash
+node scripts/sync-openclaw-agent-feed.mjs /tmp/openclaw-agents.json
+```
+
+## 五、开机自启
+
+建议使用 `launchd`。
+
+你可以参考:
+
+- [com.lan-reader-chat.web.plist](C:\Users\LA\Documents\New%20project\deploy\macos\com.lan-reader-chat.web.plist)
+- [com.lan-reader-chat.agentfeed.plist](C:\Users\LA\Documents\New%20project\deploy\macos\com.lan-reader-chat.agentfeed.plist)
+
+安装方式示例:
+
+```bash
+mkdir -p ~/Library/LaunchAgents
+cp deploy/macos/com.lan-reader-chat.web.plist ~/Library/LaunchAgents/
+cp deploy/macos/com.lan-reader-chat.agentfeed.plist ~/Library/LaunchAgents/
+launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.lan-reader-chat.web.plist
+launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/com.lan-reader-chat.agentfeed.plist
+launchctl enable gui/$(id -u)/com.lan-reader-chat.web
+launchctl enable gui/$(id -u)/com.lan-reader-chat.agentfeed
+```
+
+## 六、更新流程
+
+每次更新建议按这个顺序:
+
+1. 备份数据
+2. 拉代码
+3. 安装依赖
+4. 重新构建
+5. 重启服务
+6. 做回归验证
+
+示例:
+
+```bash
+cd /Users/ops/lan-reader-chat
+cp storage/chat.sqlite storage/chat.sqlite.bak
+git pull
+npm install
+npm run build
+launchctl kickstart -k gui/$(id -u)/com.lan-reader-chat.web
+```
+
+## 七、部署后必须验证
+
+至少检查:
+
+1. 首页是否可访问
+2. 聊天室是否可发文字
+3. 图片 / 文件是否可上传
+4. 聊天记录在服务重启后是否保留
+5. `/agents` 是否能显示真实 OpenClaw 状态
+6. 小说书架和阅读页是否正常打开
+
+## 八、恢复与回滚
+
+如果更新后异常:
+
+1. 停止服务
+2. 恢复 `chat.sqlite`
+3. 恢复上一个版本代码
+4. 重新 build
+5. 重新启动
+
+重点保护目录:
+
+- `storage/chat.sqlite`
+- `storage/chat-uploads/`
+- `storage/agents/openclaw-agents.json`

+ 47 - 0
docs/openclaw-agent-feed.example.json

@@ -0,0 +1,47 @@
+{
+  "fetchedAt": "2026-03-27T14:30:00.000Z",
+  "agents": [
+    {
+      "id": "main-orchestrator",
+      "name": "main",
+      "role": "主调度 Agent",
+      "avatarKind": "male-dispatcher",
+      "status": "working",
+      "statusLabel": "工作中",
+      "host": "macbook-ops.local",
+      "owner": "OpenClaw Core",
+      "currentTask": "拆分今天任务并下发给下游 agent",
+      "taskId": "orch-20260327-0841",
+      "taskStage": "dispatching",
+      "queueDepth": 3,
+      "todayCompleted": 18,
+      "uptime": "12天 4小时",
+      "lastHeartbeat": "2秒前",
+      "updatedAt": "刚刚",
+      "lastOutput": "已完成一轮任务编排,等待下游 agent 回传结果。",
+      "lastError": "",
+      "tags": ["调度", "编排", "主控"]
+    },
+    {
+      "id": "ops-updater",
+      "name": "ops-updater",
+      "role": "运维巡检 Agent",
+      "avatarKind": "male-ops",
+      "status": "warning",
+      "statusLabel": "待确认",
+      "host": "macbook-ops.local",
+      "owner": "OpenClaw Infra",
+      "currentTask": "检查网站服务与磁盘空间",
+      "taskId": "ops-20260327-0932",
+      "taskStage": "waiting_ack",
+      "queueDepth": 1,
+      "todayCompleted": 14,
+      "uptime": "22天 1小时",
+      "lastHeartbeat": "8秒前",
+      "updatedAt": "2分钟前",
+      "lastOutput": "巡检中发现 1 条需要人工确认的告警。",
+      "lastError": "disk_usage_warning",
+      "tags": ["安全", "巡检", "维护"]
+    }
+  ]
+}

+ 94 - 0
docs/openclaw-agent-feed.md

@@ -0,0 +1,94 @@
+# OpenClaw Agent 数据接入说明
+
+## 推荐方式
+
+推荐 `自动获取为主,ops 兜底修正`,不要让 ops 手动维护整个页面内容。
+
+最适合当前局域网部署的一版是:
+
+1. `OpenClaw` 或 `ops` agent 定时生成一份 JSON 文件
+2. 把文件写到:
+   - `storage/agents/openclaw-agents.json`
+3. 网站的 `/api/agents` 自动读取这份文件
+4. 如果文件不存在,再回退到演示数据
+
+这样做的好处:
+
+- 不需要网站直接耦合 OpenClaw 内部实现
+- 数据源坏了时,网站不会直接崩
+- 后续迁移到 MacBook 很简单
+- ops agent 只需要负责“产出标准 JSON”
+
+## 当前页面真正需要的字段
+
+现在 Agent 页已经按任务监控页收成紧凑工作列表,重点展示:
+
+- `id`
+- `name`
+- `role`
+- `status`
+- `statusLabel`
+- `host`
+- `owner`
+- `currentTask`
+- `taskId`
+- `taskStage`
+- `queueDepth`
+- `todayCompleted`
+- `uptime`
+- `lastHeartbeat`
+- `updatedAt`
+- `lastOutput`
+- `lastError`
+- `tags`
+
+## JSON 示例
+
+```json
+{
+  "fetchedAt": "2026-03-27T12:30:00.000Z",
+  "agents": [
+    {
+      "id": "main-orchestrator",
+      "name": "main",
+      "role": "主调度 Agent",
+      "avatarKind": "male-dispatcher",
+      "status": "working",
+      "statusLabel": "工作中",
+      "host": "macbook-ops.local",
+      "owner": "OpenClaw Core",
+      "currentTask": "拆分今日任务并下发给下游 agent",
+      "taskId": "orch-20260327-0841",
+      "taskStage": "dispatching",
+      "queueDepth": 3,
+      "todayCompleted": 18,
+      "uptime": "12天 4小时",
+      "lastHeartbeat": "2秒前",
+      "updatedAt": "刚刚",
+      "lastOutput": "已完成一轮任务编排,正在等待下游结果。",
+      "lastError": "",
+      "tags": ["调度", "编排", "主控"]
+    }
+  ]
+}
+```
+
+## ops 运维专员应该做什么
+
+`ops` agent 不应该手工逐条编辑页面内容,而应该:
+
+1. 负责采集 OpenClaw agent 状态
+2. 负责把状态转成标准 JSON
+3. 定时刷新本地 JSON 文件
+4. 在 OpenClaw 数据异常时写入 `lastError`
+5. 在没有真实数据时告警,而不是伪造状态
+
+## 后续更进一步
+
+如果后面要做强实时版本,可以再从“本地 JSON 文件”升级到:
+
+- 本地 HTTP 状态接口
+- WebSocket / SSE 推送
+- SQLite 历史状态记录
+
+但第一版局域网部署,先用本地 JSON 文件是最稳的。

+ 95 - 0
docs/openclaw-handoff.md

@@ -0,0 +1,95 @@
+# 交给 OpenClaw 的部署说明
+
+## 最推荐的交付方式
+
+最推荐的是:
+
+1. 你把当前项目放到一个 Git 仓库
+2. 把仓库地址发给 OpenClaw
+3. 再把下面这些文档路径一起发给它
+
+这样最好维护,也方便后续更新。
+
+## 不推荐的方式
+
+不建议只把零散文件一股脑发给 OpenClaw。
+
+原因:
+
+1. 它后面更新时不方便对比变更
+2. 回滚困难
+3. 容易漏文件
+4. ops 长期维护不稳定
+
+## 如果暂时没有 Git 仓库
+
+也可以先把整个项目目录打包成 zip 发给它,但这只是临时方案。
+
+更稳的顺序仍然是:
+
+1. 先建 Git 仓库
+2. 再让 OpenClaw 做部署和 ops 接管
+
+## 你应该发给 OpenClaw 的内容
+
+至少发这几样:
+
+1. 项目仓库地址
+2. 部署目标说明:
+   - 部署到一台局域网内的 MacBook
+   - 给公司内网用户访问
+   - 网站端口默认 `3000`
+3. 这几份文档:
+   - [macbook-deployment.md](C:\Users\LA\Documents\New%20project\docs\macbook-deployment.md)
+   - [openclaw-ops-runbook.md](C:\Users\LA\Documents\New%20project\docs\openclaw-ops-runbook.md)
+   - [openclaw-agent-feed.md](C:\Users\LA\Documents\New%20project\docs\openclaw-agent-feed.md)
+   - [openclaw-agent-feed.example.json](C:\Users\LA\Documents\New%20project\docs\openclaw-agent-feed.example.json)
+4. 告诉它:
+   - 聊天消息使用 SQLite
+   - 上传文件保存在本地目录
+   - agent 页面优先读取 `storage/agents/openclaw-agents.json`
+
+## 可以直接发给 OpenClaw 的任务说明
+
+你可以直接把下面这段发给它:
+
+```text
+请接管这个局域网网站的部署与日常运维。目标是在一台 MacBook 上部署该网站,供公司局域网用户访问。请先阅读项目中的部署与运维文档,再完成以下事项:
+
+1. 在 MacBook 上准备运行环境
+2. 按 docs/macbook-deployment.md 完成部署
+3. 配置 launchd 开机自启
+4. 配置 OpenClaw agent 状态文件同步
+5. 验证首页、聊天、书架、阅读页、Agent 观察室都可访问
+6. 验证聊天可发送文字、图片、文件,并且消息可持久化
+7. 验证 /agents 已正确读取真实数据源,而不是 demo 数据
+8. 后续作为 ops 运维专员持续维护该网站
+```
+
+## 当前项目已经具备的基础
+
+目前这个项目已经有:
+
+1. 局域网聊天
+2. 小说书架和阅读页
+3. Agent 观察页
+4. SQLite 聊天持久化
+5. 本地文件上传
+6. OpenClaw agent JSON 数据接入骨架
+7. MacBook 部署文档
+8. ops 运维手册
+
+## 你接下来最该做的事
+
+最建议你现在做这两步:
+
+1. 把这个项目提交到一个 Git 仓库
+2. 把仓库地址和这份 handoff 文档一起发给 OpenClaw
+
+## 如果你让我继续
+
+我下一步可以继续帮你做:
+
+1. 再做一轮“部署前总验收”
+2. 帮你整理一份更短的“发给 OpenClaw 的最终消息”
+3. 如果你本地已经初始化了 Git,我也可以继续帮你检查仓库状态和提交准备情况

+ 106 - 0
docs/openclaw-ops-runbook.md

@@ -0,0 +1,106 @@
+# OpenClaw ops 运维专员运行手册
+
+## 一、角色定位
+
+`ops 运维专员` 的职责不是手工维护页面内容,而是保障这套局域网网站在 MacBook 上稳定运行。
+
+它主要负责:
+
+1. 网站服务启动、停止、重启
+2. 局域网访问连通性检查
+3. 聊天数据库、上传目录、agent 状态文件的健康检查
+4. OpenClaw agent 状态采集链路的检查
+5. 更新前备份、更新后回归验证
+6. 异常发现、记录和告警
+
+## 二、ops 的日常检查项
+
+建议每天至少巡检一次:
+
+1. 网站首页是否可访问
+2. 聊天接口是否返回正常
+3. `/agents` 是否显示真实数据还是回退到 demo 数据
+4. `storage/chat.sqlite` 是否存在且大小正常
+5. `storage/chat-uploads/` 是否持续增长异常
+6. `storage/agents/openclaw-agents.json` 最近更新时间是否正常
+7. 磁盘空间是否低于安全阈值
+
+## 三、推荐告警条件
+
+建议当以下情况出现时主动告警:
+
+1. 网站主页返回非 `200`
+2. `/api/chat` 返回非 `200`
+3. `/api/agents` 已经回退到 demo 数据
+4. `openclaw-agents.json` 超过 5 分钟未刷新
+5. `chat.sqlite` 无法访问
+6. 上传目录写入失败
+7. 服务进程不存在
+8. 磁盘使用率超过 85%
+
+## 四、ops 对 OpenClaw agent 状态的要求
+
+ops 不应伪造 agent 状态。
+
+如果采集失败:
+
+1. 保留最后一次成功写入的数据
+2. 在新状态里写明 `lastError`
+3. 必要时把对应 agent 状态标记为 `warning` 或 `offline`
+
+## 五、ops 应执行的维护动作
+
+### 1. 重启网站
+
+```bash
+launchctl kickstart -k gui/$(id -u)/com.lan-reader-chat.web
+```
+
+### 2. 重跑 agent feed 同步
+
+```bash
+launchctl kickstart -k gui/$(id -u)/com.lan-reader-chat.agentfeed
+```
+
+### 3. 检查日志
+
+建议查看:
+
+```text
+/Users/ops/lan-reader-chat/logs/web.out.log
+/Users/ops/lan-reader-chat/logs/web.err.log
+/Users/ops/lan-reader-chat/logs/agentfeed.out.log
+/Users/ops/lan-reader-chat/logs/agentfeed.err.log
+```
+
+## 六、推荐给 OpenClaw 的 ops prompt
+
+你可以把下面这段直接发给 OpenClaw:
+
+```text
+你是这个局域网网站的 ops 运维专员。你的职责是保障网站在 MacBook 上稳定运行,负责服务启停、日志巡检、局域网访问排障、数据备份恢复、版本更新和基础环境维护。你不负责产品设计决策,但需要在发现部署风险、数据丢失风险、磁盘/端口/进程问题、agent 状态采集异常时主动告警,并给出可执行处理建议。你应优先通过标准数据文件和服务日志判断问题,不要伪造 agent 状态,也不要手工修改页面内容来掩盖运行异常。
+```
+
+## 七、ops 交付给网站的标准数据
+
+ops 或 OpenClaw 只需要负责把标准 JSON 放到:
+
+```text
+storage/agents/openclaw-agents.json
+```
+
+字段格式参考:
+
+- [openclaw-agent-feed.md](C:\Users\LA\Documents\New%20project\docs\openclaw-agent-feed.md)
+- [openclaw-agent-feed.example.json](C:\Users\LA\Documents\New%20project\docs\openclaw-agent-feed.example.json)
+
+## 八、更新回归清单
+
+每次更新后,ops 至少确认:
+
+1. `/` 打开正常
+2. `/chat` 能发送文字
+3. 文件上传可用
+4. 服务重启后聊天记录仍保留
+5. `/agents` 显示真实数据源
+6. `/library` 和阅读页正常

+ 286 - 0
docs/site-plan.md

@@ -0,0 +1,286 @@
+# 网站一期方案
+
+## 已确认范围
+
+- 小说内容:先内置几本演示书
+- 聊天室:只做一个公共聊天室
+- 使用范围:仅局域网内使用,暂不开放公网
+- 终端适配:一开始就兼容 PC 和手机
+
+## 目标
+
+在一个网站里先做两个模块:
+
+1. 局域网聊天室
+2. 小说书架 + 阅读器
+
+第三个功能后续再扩展,因此一期架构要支持继续加模块。
+
+## 推荐技术栈
+
+- 前端:Next.js + React + TypeScript
+- UI:Tailwind CSS
+- 实时通信:Socket.IO
+- 后端接口:Next.js Route Handlers
+- 数据库:SQLite(开发期)/ PostgreSQL(后续可切换)
+- 文件存储:本地 `uploads/` 目录(一期),后续可换对象存储
+
+## 整体信息架构
+
+- `/`
+  - 首页,显示两个入口
+- `/chat`
+  - 局域网聊天室
+- `/library`
+  - 小说书架
+- `/reader/[bookId]`
+  - 小说阅读页
+
+## 功能一:局域网聊天室
+
+### 用户进入即自动注册
+
+用户首次进入网站时,系统自动创建一个临时用户,不需要注册登录。
+
+一期用户名建议由以下信息组合生成:
+
+- 设备类型:桌面端 / 手机 / 平板
+- 浏览器:Chrome / Edge / Safari 等
+- 局域网 IP:优先取服务端看到的客户端 IP
+
+示例:
+
+- `Windows-Chrome-192.168.1.23`
+- `iPhone-Safari-192.168.1.15`
+
+### 需要注意
+
+- 浏览器端无法稳定直接拿到真实局域网 IP,需要服务端基于请求来源识别。
+- 如果用户都经过同一网关,服务端拿到的可能不是理想的内网 IP,所以建议再加一个短随机后缀,避免重名。
+
+示例最终名:
+
+- `Windows-Chrome-192.168.1.23-7F2A`
+
+### 聊天室能力
+
+- 默认只有一个公共聊天室
+- 支持发送文本消息
+- 支持发送图片
+- 支持发送普通文件
+- 支持粘贴图片直接发送
+- 支持粘贴文件后发送
+- 支持拖拽文件到聊天区发送
+- 显示在线成员列表
+- 显示消息时间
+- 区分自己消息和他人消息
+
+### 一期建议不做
+
+- 私聊
+- 群组管理
+- 消息撤回
+- 已读未读
+- 语音视频
+- 消息长期云端同步
+
+### 聊天模块页面结构
+
+- 左侧:在线成员列表
+- 中间:消息流
+- 底部:输入区
+
+输入区包含:
+
+- 文本输入框
+- 图片/文件上传按钮
+- 粘贴提示
+- 发送按钮
+
+移动端调整:
+
+- 在线成员列表改为顶部抽屉
+- 消息区铺满屏幕宽度
+- 输入区固定底部
+- 文件上传按钮与发送按钮做大点击区域
+
+### 聊天核心数据
+
+#### users
+
+- `id`
+- `display_name`
+- `device_name`
+- `browser_name`
+- `ip_address`
+- `session_id`
+- `created_at`
+- `last_seen_at`
+
+#### messages
+
+- `id`
+- `room_id`
+- `user_id`
+- `type`:`text` / `image` / `file`
+- `content`
+- `file_name`
+- `file_path`
+- `file_size`
+- `mime_type`
+- `created_at`
+
+### 局域网场景下的实现建议
+
+- 以网页作为统一入口,服务运行在你的某一台电脑上
+- 其他设备通过局域网地址访问,例如:`http://192.168.1.10:3000`
+- 文件上传后保存到服务端本地目录,聊天室消息里保留下载链接
+- 图片消息直接在聊天流里预览
+- 普通文件显示文件名、大小、下载按钮
+
+### 自动用户名规则
+
+建议采用:
+
+- `设备类型-浏览器-IP后缀-短随机码`
+
+示例:
+
+- `Windows-Chrome-1.23-A8F2`
+- `iPhone-Safari-1.15-K3M7`
+
+这样做的原因:
+
+- 局域网内可读性强
+- 多台设备同时在线时不容易重名
+- 不需要用户手动注册
+
+#### rooms
+
+- `id`
+- `name`
+
+## 功能二:小说书架 + 阅读器
+
+### 书架模块
+
+书架页负责展示可阅读的书籍列表。
+
+一期建议:
+
+- 先内置几本演示书
+- 书架可显示封面、书名、作者、简介
+- 点击后进入阅读页
+- 支持记住阅读进度
+
+### 阅读页目标
+
+阅读页参考 QQ 阅读这种方向:
+
+- 页面干净
+- 正文区域居中
+- 行高舒服
+- 段落清晰
+- 顶部工具轻量
+- 阅读设置完整但不复杂
+
+### 阅读页布局建议
+
+- 顶部:返回、书名、目录、阅读设置
+- 中间:正文阅读区域
+- 底部:上一章 / 下一章
+
+阅读设置建议包含:
+
+- 字号调整
+- 行高调整
+- 页面宽度切换
+- 背景主题切换
+- 目录面板
+
+移动端调整:
+
+- 顶部工具栏高度更紧凑
+- 目录和设置用底部弹层或侧滑面板
+- 正文区左右留白缩小
+- 上一章/下一章按钮固定在底部工具区
+
+### 视觉方向
+
+阅读页采用偏克制的排版:
+
+- 主内容窄栏居中
+- 背景使用暖白、浅米色、浅灰三套主题
+- 标题和正文层级清晰
+- 减少过多边框和装饰
+
+书架页视觉建议:
+
+- PC 端使用卡片网格布局
+- 手机端切成双列或单列瀑布感卡片
+- 封面比例统一,信息层次简洁
+
+### 小说核心数据
+
+#### books
+
+- `id`
+- `title`
+- `author`
+- `cover_url`
+- `description`
+- `category`
+- `word_count`
+- `created_at`
+
+#### chapters
+
+- `id`
+- `book_id`
+- `title`
+- `chapter_index`
+- `content`
+- `word_count`
+
+#### reading_progress
+
+- `id`
+- `user_id`
+- `book_id`
+- `chapter_id`
+- `scroll_percent`
+- `updated_at`
+
+## 一期开发建议
+
+### 第一阶段
+
+- 搭建项目基础框架
+- 首页和两个模块入口
+- 聊天室基础 UI
+- 自动临时用户
+- 文本聊天
+- 图片/文件上传
+- 书架页
+- 阅读页基础排版
+- 手机端响应式布局
+- 内置演示书数据
+
+### 第二阶段
+
+- 在线成员状态优化
+- 粘贴图片/文件体验优化
+- 阅读设置面板
+- 阅读进度保存
+- 章节目录
+
+## 我建议的实现顺序
+
+1. 先把网站主框架和路由搭起来
+2. 完成聊天室最小可用版
+3. 完成书架和阅读页最小可用版
+4. 再补体验层功能
+
+## 当前最需要你确认的 4 个点
+
+以上 4 个点已经确认完成,可以直接进入原型和开发阶段。

+ 91 - 0
lib/agent-monitor.ts

@@ -0,0 +1,91 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+import { demoAgents } from "@/data/demo-agents";
+import { AgentFeed, AgentProfile, AgentStatus } from "@/types/agent";
+
+const defaultFeedPath = path.join(process.cwd(), "storage", "agents", "openclaw-agents.json");
+
+function isAgentStatus(value: unknown): value is AgentStatus {
+  return value === "working" || value === "idle" || value === "warning" || value === "offline";
+}
+
+function normalizeAgent(agent: Record<string, unknown>): AgentProfile | null {
+  if (typeof agent.id !== "string" || typeof agent.name !== "string" || typeof agent.role !== "string") {
+    return null;
+  }
+
+  const status = isAgentStatus(agent.status) ? agent.status : "offline";
+  const statusLabelMap: Record<AgentStatus, string> = {
+    working: "工作中",
+    idle: "待命",
+    warning: "待确认",
+    offline: "离线"
+  };
+
+  return {
+    id: agent.id,
+    name: agent.name,
+    role: agent.role,
+    avatarKind: (agent.avatarKind as AgentProfile["avatarKind"]) ?? "male-dispatcher",
+    status,
+    statusLabel: typeof agent.statusLabel === "string" ? agent.statusLabel : statusLabelMap[status],
+    host: typeof agent.host === "string" ? agent.host : "unknown-host",
+    owner: typeof agent.owner === "string" ? agent.owner : "OpenClaw",
+    currentTask: typeof agent.currentTask === "string" ? agent.currentTask : "未上报当前任务",
+    taskId: typeof agent.taskId === "string" ? agent.taskId : "unknown",
+    taskStage: typeof agent.taskStage === "string" ? agent.taskStage : "unknown",
+    queueDepth: typeof agent.queueDepth === "number" ? agent.queueDepth : 0,
+    todayCompleted: typeof agent.todayCompleted === "number" ? agent.todayCompleted : 0,
+    uptime: typeof agent.uptime === "string" ? agent.uptime : "未知",
+    lastHeartbeat: typeof agent.lastHeartbeat === "string" ? agent.lastHeartbeat : "未知",
+    updatedAt: typeof agent.updatedAt === "string" ? agent.updatedAt : "刚刚",
+    lastOutput: typeof agent.lastOutput === "string" ? agent.lastOutput : "暂无最近输出。",
+    lastError: typeof agent.lastError === "string" ? agent.lastError : undefined,
+    tags: Array.isArray(agent.tags) ? agent.tags.filter((tag): tag is string => typeof tag === "string") : []
+  };
+}
+
+async function loadFileFeed(feedPath: string): Promise<AgentFeed | null> {
+  try {
+    const raw = await fs.readFile(feedPath, "utf8");
+    const parsed = JSON.parse(raw) as {
+      fetchedAt?: string;
+      agents?: Array<Record<string, unknown>>;
+    };
+
+    if (!Array.isArray(parsed.agents)) {
+      return null;
+    }
+
+    const agents = parsed.agents.map(normalizeAgent).filter((agent): agent is AgentProfile => agent !== null);
+
+    return {
+      source: "file",
+      sourceLabel: path.basename(feedPath),
+      fetchedAt: typeof parsed.fetchedAt === "string" ? parsed.fetchedAt : new Date().toISOString(),
+      agents
+    };
+  } catch {
+    return null;
+  }
+}
+
+export async function loadAgentFeed(status?: "all" | AgentStatus): Promise<AgentFeed> {
+  const feedPath = process.env.OPENCLAW_AGENT_FEED_PATH ?? defaultFeedPath;
+  const fileFeed = await loadFileFeed(feedPath);
+  const fallbackFeed: AgentFeed = {
+    source: "demo",
+    sourceLabel: "demo-agents.ts",
+    fetchedAt: new Date().toISOString(),
+    agents: demoAgents
+  };
+
+  const chosenFeed = fileFeed ?? fallbackFeed;
+  const filteredAgents =
+    status && status !== "all" ? chosenFeed.agents.filter((agent) => agent.status === status) : chosenFeed.agents;
+
+  return {
+    ...chosenFeed,
+    agents: filteredAgents
+  };
+}

+ 247 - 0
lib/chat-store.ts

@@ -0,0 +1,247 @@
+import Database from "better-sqlite3";
+import { existsSync, mkdirSync, rmSync } from "node:fs";
+import { readFile } from "node:fs/promises";
+import path from "node:path";
+
+export type ChatAttachment = {
+  name: string;
+  meta: string;
+  kind: "image" | "file";
+  url?: string;
+  storageKey?: string;
+};
+
+export type ChatMessage = {
+  id: string;
+  user: string;
+  time: string;
+  content: string;
+  file?: ChatAttachment;
+};
+
+type MessageRow = {
+  id: string;
+  user: string;
+  time: string;
+  content: string;
+  file_name: string | null;
+  file_meta: string | null;
+  file_kind: "image" | "file" | null;
+  file_storage_key: string | null;
+  created_at: number;
+};
+
+const storageRoot = path.join(process.cwd(), "storage");
+const uploadRoot = path.join(storageRoot, "chat-uploads");
+const dbPath = path.join(storageRoot, "chat.sqlite");
+
+function ensureStorage() {
+  if (!existsSync(storageRoot)) {
+    mkdirSync(storageRoot, { recursive: true });
+  }
+
+  if (!existsSync(uploadRoot)) {
+    mkdirSync(uploadRoot, { recursive: true });
+  }
+}
+
+ensureStorage();
+
+const db = new Database(dbPath);
+db.pragma("journal_mode = WAL");
+
+db.exec(`
+  CREATE TABLE IF NOT EXISTS chat_messages (
+    id TEXT PRIMARY KEY,
+    user TEXT NOT NULL,
+    time TEXT NOT NULL,
+    content TEXT NOT NULL DEFAULT '',
+    file_name TEXT,
+    file_meta TEXT,
+    file_kind TEXT,
+    file_storage_key TEXT,
+    created_at INTEGER NOT NULL
+  );
+
+  CREATE TABLE IF NOT EXISTS chat_presence (
+    user TEXT PRIMARY KEY,
+    last_seen INTEGER NOT NULL
+  );
+`);
+
+const selectMessagesStmt = db.prepare(`
+  SELECT id, user, time, content, file_name, file_meta, file_kind, file_storage_key, created_at
+  FROM chat_messages
+  ORDER BY created_at ASC
+`);
+
+const insertMessageStmt = db.prepare(`
+  INSERT INTO chat_messages (
+    id,
+    user,
+    time,
+    content,
+    file_name,
+    file_meta,
+    file_kind,
+    file_storage_key,
+    created_at
+  ) VALUES (
+    @id,
+    @user,
+    @time,
+    @content,
+    @file_name,
+    @file_meta,
+    @file_kind,
+    @file_storage_key,
+    @created_at
+  )
+`);
+
+const trimMessagesStmt = db.prepare(`
+  DELETE FROM chat_messages
+  WHERE id IN (
+    SELECT id
+    FROM chat_messages
+    ORDER BY created_at DESC
+    LIMIT -1 OFFSET 500
+  )
+`);
+
+const touchPresenceStmt = db.prepare(`
+  INSERT INTO chat_presence (user, last_seen)
+  VALUES (?, ?)
+  ON CONFLICT(user) DO UPDATE SET last_seen = excluded.last_seen
+`);
+
+const deleteExpiredPresenceStmt = db.prepare(`
+  DELETE FROM chat_presence
+  WHERE last_seen < ?
+`);
+
+const listPresenceStmt = db.prepare(`
+  SELECT user
+  FROM chat_presence
+  ORDER BY last_seen DESC
+`);
+
+function formatCurrentTime() {
+  return new Intl.DateTimeFormat("zh-CN", {
+    hour: "2-digit",
+    minute: "2-digit",
+    hour12: false
+  }).format(new Date());
+}
+
+function toFileUrl(storageKey: string) {
+  return `/api/chat/files/${encodeURIComponent(storageKey)}`;
+}
+
+function mapMessage(row: MessageRow): ChatMessage {
+  return {
+    id: row.id,
+    user: row.user,
+    time: row.time,
+    content: row.content,
+    file: row.file_name && row.file_meta && row.file_kind && row.file_storage_key
+      ? {
+          name: row.file_name,
+          meta: row.file_meta,
+          kind: row.file_kind,
+          storageKey: row.file_storage_key,
+          url: toFileUrl(row.file_storage_key)
+        }
+      : undefined
+  };
+}
+
+export function formatUploadedFileMeta(fileName: string, fileType: string, fileSize: number) {
+  const sizeMb = fileSize / (1024 * 1024);
+  const size = sizeMb >= 1 ? `${sizeMb.toFixed(1)} MB` : `${Math.max(1, Math.round(fileSize / 1024))} KB`;
+  return `${size} · ${fileType || "application/octet-stream"}`;
+}
+
+export function touchPresence(user?: string) {
+  const name = user?.trim();
+
+  if (!name) {
+    return;
+  }
+
+  touchPresenceStmt.run(name, Date.now());
+}
+
+export function getOnlineUsers() {
+  deleteExpiredPresenceStmt.run(Date.now() - 45_000);
+  return (listPresenceStmt.all() as Array<{ user: string }>).map((row) => row.user);
+}
+
+export function listMessages() {
+  return (selectMessagesStmt.all() as MessageRow[]).map(mapMessage);
+}
+
+export function addMessage(input: {
+  user: string;
+  content?: string;
+  file?: {
+    name: string;
+    meta: string;
+    kind: "image" | "file";
+    storageKey: string;
+  };
+}) {
+  const record = {
+    id: crypto.randomUUID(),
+    user: input.user.trim() || "局域网设备",
+    time: formatCurrentTime(),
+    content: input.content?.trim() || "",
+    file_name: input.file?.name || null,
+    file_meta: input.file?.meta || null,
+    file_kind: input.file?.kind || null,
+    file_storage_key: input.file?.storageKey || null,
+    created_at: Date.now()
+  };
+
+  insertMessageStmt.run(record);
+  trimMessagesStmt.run();
+
+  return mapMessage(record);
+}
+
+export async function clearMessages(clearedBy: string) {
+  const rows = selectMessagesStmt.all() as MessageRow[];
+  const storageKeys = rows
+    .map((row) => row.file_storage_key)
+    .filter((value): value is string => Boolean(value));
+
+  for (const storageKey of storageKeys) {
+    try {
+      rmSync(path.join(uploadRoot, storageKey), { force: true });
+    } catch {
+      // ignore missing files during cleanup
+    }
+  }
+
+  db.prepare("DELETE FROM chat_messages").run();
+
+  const systemMessage = addMessage({
+    user: "系统消息",
+    content: `${clearedBy} 清理了服务器历史聊天记录。`
+  });
+
+  return systemMessage;
+}
+
+export function resolveUploadPath(storageKey: string) {
+  return path.join(uploadRoot, storageKey);
+}
+
+export async function readUploadBuffer(storageKey: string) {
+  return readFile(resolveUploadPath(storageKey));
+}
+
+export function createStorageKey(originalName: string) {
+  const extension = path.extname(originalName);
+  return `${Date.now()}-${crypto.randomUUID()}${extension}`;
+}

+ 6 - 0
next-env.d.ts

@@ -0,0 +1,6 @@
+/// <reference types="next" />
+/// <reference types="next/image-types/global" />
+/// <reference path="./.next/types/routes.d.ts" />
+
+// NOTE: This file should not be edited
+// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

+ 61 - 0
next.config.ts

@@ -0,0 +1,61 @@
+import type { NextConfig } from "next";
+
+const nextConfig: NextConfig = {
+  images: {
+    remotePatterns: [
+      {
+        protocol: "https",
+        hostname: "images.unsplash.com"
+      }
+    ]
+  },
+  async headers() {
+    const noStoreHeaders = [
+      {
+        key: "Cache-Control",
+        value: "no-store, no-cache, must-revalidate, proxy-revalidate"
+      },
+      {
+        key: "Pragma",
+        value: "no-cache"
+      },
+      {
+        key: "Expires",
+        value: "0"
+      }
+    ];
+
+    return [
+      {
+        source: "/",
+        headers: noStoreHeaders
+      },
+      {
+        source: "/chat",
+        headers: noStoreHeaders
+      },
+      {
+        source: "/library",
+        headers: noStoreHeaders
+      },
+      {
+        source: "/agents",
+        headers: noStoreHeaders
+      },
+      {
+        source: "/agents/:path*",
+        headers: noStoreHeaders
+      },
+      {
+        source: "/reader/:path*",
+        headers: noStoreHeaders
+      },
+      {
+        source: "/api/:path*",
+        headers: noStoreHeaders
+      }
+    ];
+  }
+};
+
+export default nextConfig;

+ 1406 - 0
package-lock.json

@@ -0,0 +1,1406 @@
+{
+  "name": "lan-reader-chat",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "lan-reader-chat",
+      "version": "0.1.0",
+      "dependencies": {
+        "better-sqlite3": "^12.8.0",
+        "next": "15.5.14",
+        "react": "19.1.0",
+        "react-dom": "19.1.0"
+      },
+      "devDependencies": {
+        "@types/node": "22.15.21",
+        "@types/react": "19.1.5",
+        "@types/react-dom": "19.1.5",
+        "playwright-core": "^1.58.2",
+        "typescript": "5.8.3"
+      }
+    },
+    "node_modules/@emnapi/runtime": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.9.1.tgz",
+      "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==",
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "tslib": "^2.4.0"
+      }
+    },
+    "node_modules/@img/colour": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/@img/colour/-/colour-1.1.0.tgz",
+      "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
+      "license": "MIT",
+      "optional": true,
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@img/sharp-darwin-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmmirror.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
+      "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-darwin-arm64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-darwin-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmmirror.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
+      "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-darwin-x64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-libvips-darwin-arm64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
+      "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-darwin-x64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
+      "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-arm": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
+      "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
+      "cpu": [
+        "arm"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-arm64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
+      "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-ppc64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
+      "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-riscv64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
+      "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-s390x": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
+      "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
+      "cpu": [
+        "s390x"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linux-x64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
+      "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
+      "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-libvips-linuxmusl-x64": {
+      "version": "1.2.4",
+      "resolved": "https://registry.npmmirror.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
+      "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-linux-arm": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmmirror.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
+      "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
+      "cpu": [
+        "arm"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-arm": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmmirror.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
+      "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-arm64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-ppc64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmmirror.com/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
+      "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-ppc64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-riscv64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmmirror.com/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
+      "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
+      "cpu": [
+        "riscv64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-riscv64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-s390x": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmmirror.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
+      "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
+      "cpu": [
+        "s390x"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-s390x": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linux-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmmirror.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
+      "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linux-x64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linuxmusl-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmmirror.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
+      "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-linuxmusl-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmmirror.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
+      "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "Apache-2.0",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-libvips-linuxmusl-x64": "1.2.4"
+      }
+    },
+    "node_modules/@img/sharp-wasm32": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmmirror.com/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
+      "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
+      "cpu": [
+        "wasm32"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
+      "optional": true,
+      "dependencies": {
+        "@emnapi/runtime": "^1.7.0"
+      },
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-arm64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmmirror.com/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
+      "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-ia32": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmmirror.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
+      "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
+      "cpu": [
+        "ia32"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@img/sharp-win32-x64": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmmirror.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
+      "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "Apache-2.0 AND LGPL-3.0-or-later",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      }
+    },
+    "node_modules/@next/env": {
+      "version": "15.5.14",
+      "resolved": "https://registry.npmmirror.com/@next/env/-/env-15.5.14.tgz",
+      "integrity": "sha512-aXeirLYuASxEgi4X4WhfXsShCFxWDfNn/8ZeC5YXAS2BB4A8FJi1kwwGL6nvMVboE7fZCzmJPNdMvVHc8JpaiA==",
+      "license": "MIT"
+    },
+    "node_modules/@next/swc-darwin-arm64": {
+      "version": "15.5.14",
+      "resolved": "https://registry.npmmirror.com/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.5.14.tgz",
+      "integrity": "sha512-Y9K6SPzobnZvrRDPO2s0grgzC+Egf0CqfbdvYmQVaztV890zicw8Z8+4Vqw8oPck8r1TjUHxVh8299Cg4TrxXg==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-darwin-x64": {
+      "version": "15.5.14",
+      "resolved": "https://registry.npmmirror.com/@next/swc-darwin-x64/-/swc-darwin-x64-15.5.14.tgz",
+      "integrity": "sha512-aNnkSMjSFRTOmkd7qoNI2/rETQm/vKD6c/Ac9BZGa9CtoOzy3c2njgz7LvebQJ8iPxdeTuGnAjagyis8a9ifBw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-linux-arm64-gnu": {
+      "version": "15.5.14",
+      "resolved": "https://registry.npmmirror.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.5.14.tgz",
+      "integrity": "sha512-tjlpia+yStPRS//6sdmlVwuO1Rioern4u2onafa5n+h2hCS9MAvMXqpVbSrjgiEOoCs0nJy7oPOmWgtRRNSM5Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-linux-arm64-musl": {
+      "version": "15.5.14",
+      "resolved": "https://registry.npmmirror.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.5.14.tgz",
+      "integrity": "sha512-8B8cngBaLadl5lbDRdxGCP1Lef8ipD6KlxS3v0ElDAGil6lafrAM3B258p1KJOglInCVFUjk751IXMr2ixeQOQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-linux-x64-gnu": {
+      "version": "15.5.14",
+      "resolved": "https://registry.npmmirror.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.5.14.tgz",
+      "integrity": "sha512-bAS6tIAg8u4Gn3Nz7fCPpSoKAexEt2d5vn1mzokcqdqyov6ZJ6gu6GdF9l8ORFrBuRHgv3go/RfzYz5BkZ6YSQ==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-linux-x64-musl": {
+      "version": "15.5.14",
+      "resolved": "https://registry.npmmirror.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.5.14.tgz",
+      "integrity": "sha512-mMxv/FcrT7Gfaq4tsR22l17oKWXZmH/lVqcvjX0kfp5I0lKodHYLICKPoX1KRnnE+ci6oIUdriUhuA3rBCDiSw==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-win32-arm64-msvc": {
+      "version": "15.5.14",
+      "resolved": "https://registry.npmmirror.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.5.14.tgz",
+      "integrity": "sha512-OTmiBlYThppnvnsqx0rBqjDRemlmIeZ8/o4zI7veaXoeO1PVHoyj2lfTfXTiiGjCyRDhA10y4h6ZvZvBiynr2g==",
+      "cpu": [
+        "arm64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@next/swc-win32-x64-msvc": {
+      "version": "15.5.14",
+      "resolved": "https://registry.npmmirror.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.5.14.tgz",
+      "integrity": "sha512-+W7eFf3RS7m4G6tppVTOSyP9Y6FsJXfOuKzav1qKniiFm3KFByQfPEcouHdjlZmysl4zJGuGLQ/M9XyVeyeNEg==",
+      "cpu": [
+        "x64"
+      ],
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10"
+      }
+    },
+    "node_modules/@swc/helpers": {
+      "version": "0.5.15",
+      "resolved": "https://registry.npmmirror.com/@swc/helpers/-/helpers-0.5.15.tgz",
+      "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "tslib": "^2.8.0"
+      }
+    },
+    "node_modules/@types/node": {
+      "version": "22.15.21",
+      "resolved": "https://registry.npmmirror.com/@types/node/-/node-22.15.21.tgz",
+      "integrity": "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~6.21.0"
+      }
+    },
+    "node_modules/@types/react": {
+      "version": "19.1.5",
+      "resolved": "https://registry.npmmirror.com/@types/react/-/react-19.1.5.tgz",
+      "integrity": "sha512-piErsCVVbpMMT2r7wbawdZsq4xMvIAhQuac2gedQHysu1TZYEigE6pnFfgZT+/jQnrRuF5r+SHzuehFjfRjr4g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "csstype": "^3.0.2"
+      }
+    },
+    "node_modules/@types/react-dom": {
+      "version": "19.1.5",
+      "resolved": "https://registry.npmmirror.com/@types/react-dom/-/react-dom-19.1.5.tgz",
+      "integrity": "sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg==",
+      "dev": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/react": "^19.0.0"
+      }
+    },
+    "node_modules/base64-js": {
+      "version": "1.5.1",
+      "resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz",
+      "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/better-sqlite3": {
+      "version": "12.8.0",
+      "resolved": "https://registry.npmmirror.com/better-sqlite3/-/better-sqlite3-12.8.0.tgz",
+      "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "dependencies": {
+        "bindings": "^1.5.0",
+        "prebuild-install": "^7.1.1"
+      },
+      "engines": {
+        "node": "20.x || 22.x || 23.x || 24.x || 25.x"
+      }
+    },
+    "node_modules/bindings": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmmirror.com/bindings/-/bindings-1.5.0.tgz",
+      "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+      "license": "MIT",
+      "dependencies": {
+        "file-uri-to-path": "1.0.0"
+      }
+    },
+    "node_modules/bl": {
+      "version": "4.1.0",
+      "resolved": "https://registry.npmmirror.com/bl/-/bl-4.1.0.tgz",
+      "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+      "license": "MIT",
+      "dependencies": {
+        "buffer": "^5.5.0",
+        "inherits": "^2.0.4",
+        "readable-stream": "^3.4.0"
+      }
+    },
+    "node_modules/buffer": {
+      "version": "5.7.1",
+      "resolved": "https://registry.npmmirror.com/buffer/-/buffer-5.7.1.tgz",
+      "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "base64-js": "^1.3.1",
+        "ieee754": "^1.1.13"
+      }
+    },
+    "node_modules/caniuse-lite": {
+      "version": "1.0.30001781",
+      "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001781.tgz",
+      "integrity": "sha512-RdwNCyMsNBftLjW6w01z8bKEvT6e/5tpPVEgtn22TiLGlstHOVecsX2KHFkD5e/vRnIE4EGzpuIODb3mtswtkw==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/browserslist"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "CC-BY-4.0"
+    },
+    "node_modules/chownr": {
+      "version": "1.1.4",
+      "resolved": "https://registry.npmmirror.com/chownr/-/chownr-1.1.4.tgz",
+      "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+      "license": "ISC"
+    },
+    "node_modules/client-only": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmmirror.com/client-only/-/client-only-0.0.1.tgz",
+      "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
+      "license": "MIT"
+    },
+    "node_modules/csstype": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
+      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/decompress-response": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmmirror.com/decompress-response/-/decompress-response-6.0.0.tgz",
+      "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+      "license": "MIT",
+      "dependencies": {
+        "mimic-response": "^3.1.0"
+      },
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/deep-extend": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmmirror.com/deep-extend/-/deep-extend-0.6.0.tgz",
+      "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/detect-libc": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz",
+      "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+      "license": "Apache-2.0",
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/end-of-stream": {
+      "version": "1.4.5",
+      "resolved": "https://registry.npmmirror.com/end-of-stream/-/end-of-stream-1.4.5.tgz",
+      "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+      "license": "MIT",
+      "dependencies": {
+        "once": "^1.4.0"
+      }
+    },
+    "node_modules/expand-template": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmmirror.com/expand-template/-/expand-template-2.0.3.tgz",
+      "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
+      "license": "(MIT OR WTFPL)",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/file-uri-to-path": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+      "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
+      "license": "MIT"
+    },
+    "node_modules/fs-constants": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/fs-constants/-/fs-constants-1.0.0.tgz",
+      "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
+      "license": "MIT"
+    },
+    "node_modules/github-from-package": {
+      "version": "0.0.0",
+      "resolved": "https://registry.npmmirror.com/github-from-package/-/github-from-package-0.0.0.tgz",
+      "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
+      "license": "MIT"
+    },
+    "node_modules/ieee754": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz",
+      "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "license": "ISC"
+    },
+    "node_modules/ini": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmmirror.com/ini/-/ini-1.3.8.tgz",
+      "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+      "license": "ISC"
+    },
+    "node_modules/mimic-response": {
+      "version": "3.1.0",
+      "resolved": "https://registry.npmmirror.com/mimic-response/-/mimic-response-3.1.0.tgz",
+      "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/minimist": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz",
+      "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/mkdirp-classic": {
+      "version": "0.5.3",
+      "resolved": "https://registry.npmmirror.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+      "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
+      "license": "MIT"
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.11",
+      "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz",
+      "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/napi-build-utils": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/napi-build-utils/-/napi-build-utils-1.0.2.tgz",
+      "integrity": "sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==",
+      "license": "MIT"
+    },
+    "node_modules/next": {
+      "version": "15.5.14",
+      "resolved": "https://registry.npmmirror.com/next/-/next-15.5.14.tgz",
+      "integrity": "sha512-M6S+4JyRjmKic2Ssm7jHUPkE6YUJ6lv4507jprsSZLulubz0ihO2E+S4zmQK3JZ2ov81JrugukKU4Tz0ivgqqQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@next/env": "15.5.14",
+        "@swc/helpers": "0.5.15",
+        "caniuse-lite": "^1.0.30001579",
+        "postcss": "8.4.31",
+        "styled-jsx": "5.1.6"
+      },
+      "bin": {
+        "next": "dist/bin/next"
+      },
+      "engines": {
+        "node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
+      },
+      "optionalDependencies": {
+        "@next/swc-darwin-arm64": "15.5.14",
+        "@next/swc-darwin-x64": "15.5.14",
+        "@next/swc-linux-arm64-gnu": "15.5.14",
+        "@next/swc-linux-arm64-musl": "15.5.14",
+        "@next/swc-linux-x64-gnu": "15.5.14",
+        "@next/swc-linux-x64-musl": "15.5.14",
+        "@next/swc-win32-arm64-msvc": "15.5.14",
+        "@next/swc-win32-x64-msvc": "15.5.14",
+        "sharp": "^0.34.3"
+      },
+      "peerDependencies": {
+        "@opentelemetry/api": "^1.1.0",
+        "@playwright/test": "^1.51.1",
+        "babel-plugin-react-compiler": "*",
+        "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+        "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
+        "sass": "^1.3.0"
+      },
+      "peerDependenciesMeta": {
+        "@opentelemetry/api": {
+          "optional": true
+        },
+        "@playwright/test": {
+          "optional": true
+        },
+        "babel-plugin-react-compiler": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/node-abi": {
+      "version": "3.89.0",
+      "resolved": "https://registry.npmmirror.com/node-abi/-/node-abi-3.89.0.tgz",
+      "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==",
+      "license": "MIT",
+      "dependencies": {
+        "semver": "^7.3.5"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/once": {
+      "version": "1.4.0",
+      "resolved": "https://registry.npmmirror.com/once/-/once-1.4.0.tgz",
+      "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
+      "license": "ISC",
+      "dependencies": {
+        "wrappy": "1"
+      }
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "license": "ISC"
+    },
+    "node_modules/playwright-core": {
+      "version": "1.58.2",
+      "resolved": "https://registry.npmmirror.com/playwright-core/-/playwright-core-1.58.2.tgz",
+      "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "playwright-core": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.4.31",
+      "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.4.31.tgz",
+      "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.6",
+        "picocolors": "^1.0.0",
+        "source-map-js": "^1.0.2"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/prebuild-install": {
+      "version": "7.1.2",
+      "resolved": "https://registry.npmmirror.com/prebuild-install/-/prebuild-install-7.1.2.tgz",
+      "integrity": "sha512-UnNke3IQb6sgarcZIDU3gbMeTp/9SSU1DAIkil7PrqG1vZlBtY5msYccSKSHDqa3hNg436IXK+SNImReuA1wEQ==",
+      "license": "MIT",
+      "dependencies": {
+        "detect-libc": "^2.0.0",
+        "expand-template": "^2.0.3",
+        "github-from-package": "0.0.0",
+        "minimist": "^1.2.3",
+        "mkdirp-classic": "^0.5.3",
+        "napi-build-utils": "^1.0.1",
+        "node-abi": "^3.3.0",
+        "pump": "^3.0.0",
+        "rc": "^1.2.7",
+        "simple-get": "^4.0.0",
+        "tar-fs": "^2.0.0",
+        "tunnel-agent": "^0.6.0"
+      },
+      "bin": {
+        "prebuild-install": "bin.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/pump": {
+      "version": "3.0.4",
+      "resolved": "https://registry.npmmirror.com/pump/-/pump-3.0.4.tgz",
+      "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+      "license": "MIT",
+      "dependencies": {
+        "end-of-stream": "^1.1.0",
+        "once": "^1.3.1"
+      }
+    },
+    "node_modules/rc": {
+      "version": "1.2.8",
+      "resolved": "https://registry.npmmirror.com/rc/-/rc-1.2.8.tgz",
+      "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+      "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+      "dependencies": {
+        "deep-extend": "^0.6.0",
+        "ini": "~1.3.0",
+        "minimist": "^1.2.0",
+        "strip-json-comments": "~2.0.1"
+      },
+      "bin": {
+        "rc": "cli.js"
+      }
+    },
+    "node_modules/react": {
+      "version": "19.1.0",
+      "resolved": "https://registry.npmmirror.com/react/-/react-19.1.0.tgz",
+      "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/react-dom": {
+      "version": "19.1.0",
+      "resolved": "https://registry.npmmirror.com/react-dom/-/react-dom-19.1.0.tgz",
+      "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
+      "license": "MIT",
+      "dependencies": {
+        "scheduler": "^0.26.0"
+      },
+      "peerDependencies": {
+        "react": "^19.1.0"
+      }
+    },
+    "node_modules/readable-stream": {
+      "version": "3.6.2",
+      "resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-3.6.2.tgz",
+      "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+      "license": "MIT",
+      "dependencies": {
+        "inherits": "^2.0.3",
+        "string_decoder": "^1.1.1",
+        "util-deprecate": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/scheduler": {
+      "version": "0.26.0",
+      "resolved": "https://registry.npmmirror.com/scheduler/-/scheduler-0.26.0.tgz",
+      "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
+      "license": "MIT"
+    },
+    "node_modules/semver": {
+      "version": "7.7.4",
+      "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz",
+      "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/sharp": {
+      "version": "0.34.5",
+      "resolved": "https://registry.npmmirror.com/sharp/-/sharp-0.34.5.tgz",
+      "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
+      "hasInstallScript": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "dependencies": {
+        "@img/colour": "^1.0.0",
+        "detect-libc": "^2.1.2",
+        "semver": "^7.7.3"
+      },
+      "engines": {
+        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
+      },
+      "funding": {
+        "url": "https://opencollective.com/libvips"
+      },
+      "optionalDependencies": {
+        "@img/sharp-darwin-arm64": "0.34.5",
+        "@img/sharp-darwin-x64": "0.34.5",
+        "@img/sharp-libvips-darwin-arm64": "1.2.4",
+        "@img/sharp-libvips-darwin-x64": "1.2.4",
+        "@img/sharp-libvips-linux-arm": "1.2.4",
+        "@img/sharp-libvips-linux-arm64": "1.2.4",
+        "@img/sharp-libvips-linux-ppc64": "1.2.4",
+        "@img/sharp-libvips-linux-riscv64": "1.2.4",
+        "@img/sharp-libvips-linux-s390x": "1.2.4",
+        "@img/sharp-libvips-linux-x64": "1.2.4",
+        "@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
+        "@img/sharp-libvips-linuxmusl-x64": "1.2.4",
+        "@img/sharp-linux-arm": "0.34.5",
+        "@img/sharp-linux-arm64": "0.34.5",
+        "@img/sharp-linux-ppc64": "0.34.5",
+        "@img/sharp-linux-riscv64": "0.34.5",
+        "@img/sharp-linux-s390x": "0.34.5",
+        "@img/sharp-linux-x64": "0.34.5",
+        "@img/sharp-linuxmusl-arm64": "0.34.5",
+        "@img/sharp-linuxmusl-x64": "0.34.5",
+        "@img/sharp-wasm32": "0.34.5",
+        "@img/sharp-win32-arm64": "0.34.5",
+        "@img/sharp-win32-ia32": "0.34.5",
+        "@img/sharp-win32-x64": "0.34.5"
+      }
+    },
+    "node_modules/simple-concat": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/simple-concat/-/simple-concat-1.0.1.tgz",
+      "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/simple-get": {
+      "version": "4.0.1",
+      "resolved": "https://registry.npmmirror.com/simple-get/-/simple-get-4.0.1.tgz",
+      "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "decompress-response": "^6.0.0",
+        "once": "^1.3.1",
+        "simple-concat": "^1.0.0"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/string_decoder": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.3.0.tgz",
+      "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "~5.2.0"
+      }
+    },
+    "node_modules/strip-json-comments": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+      "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/styled-jsx": {
+      "version": "5.1.6",
+      "resolved": "https://registry.npmmirror.com/styled-jsx/-/styled-jsx-5.1.6.tgz",
+      "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
+      "license": "MIT",
+      "dependencies": {
+        "client-only": "0.0.1"
+      },
+      "engines": {
+        "node": ">= 12.0.0"
+      },
+      "peerDependencies": {
+        "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
+      },
+      "peerDependenciesMeta": {
+        "@babel/core": {
+          "optional": true
+        },
+        "babel-plugin-macros": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/tar-fs": {
+      "version": "2.1.4",
+      "resolved": "https://registry.npmmirror.com/tar-fs/-/tar-fs-2.1.4.tgz",
+      "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==",
+      "license": "MIT",
+      "dependencies": {
+        "chownr": "^1.1.1",
+        "mkdirp-classic": "^0.5.2",
+        "pump": "^3.0.0",
+        "tar-stream": "^2.1.4"
+      }
+    },
+    "node_modules/tar-stream": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmmirror.com/tar-stream/-/tar-stream-2.2.0.tgz",
+      "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+      "license": "MIT",
+      "dependencies": {
+        "bl": "^4.0.3",
+        "end-of-stream": "^1.4.1",
+        "fs-constants": "^1.0.0",
+        "inherits": "^2.0.3",
+        "readable-stream": "^3.1.1"
+      },
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/tslib": {
+      "version": "2.8.1",
+      "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz",
+      "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+      "license": "0BSD"
+    },
+    "node_modules/tunnel-agent": {
+      "version": "0.6.0",
+      "resolved": "https://registry.npmmirror.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+      "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "safe-buffer": "^5.0.1"
+      },
+      "engines": {
+        "node": "*"
+      }
+    },
+    "node_modules/typescript": {
+      "version": "5.8.3",
+      "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.8.3.tgz",
+      "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "6.21.0",
+      "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz",
+      "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/util-deprecate": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
+      "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+      "license": "MIT"
+    },
+    "node_modules/wrappy": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/wrappy/-/wrappy-1.0.2.tgz",
+      "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
+      "license": "ISC"
+    }
+  }
+}

+ 24 - 0
package.json

@@ -0,0 +1,24 @@
+{
+  "name": "lan-reader-chat",
+  "version": "0.1.0",
+  "private": true,
+  "scripts": {
+    "dev": "next dev --turbopack",
+    "build": "next build",
+    "start": "next start",
+    "lint": "next lint"
+  },
+  "dependencies": {
+    "better-sqlite3": "^12.8.0",
+    "next": "15.5.14",
+    "react": "19.1.0",
+    "react-dom": "19.1.0"
+  },
+  "devDependencies": {
+    "@types/node": "22.15.21",
+    "@types/react": "19.1.5",
+    "@types/react-dom": "19.1.5",
+    "playwright-core": "^1.58.2",
+    "typescript": "5.8.3"
+  }
+}

+ 35 - 0
scripts/check-agents-grid.mjs

@@ -0,0 +1,35 @@
+import { chromium } from "playwright-core";
+
+const browser = await chromium.launch({
+  executablePath: "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
+  headless: true
+});
+
+const page = await browser.newPage({
+  viewport: { width: 1600, height: 1200 },
+  deviceScaleFactor: 1
+});
+
+await page.goto("http://127.0.0.1:3000/agents", { waitUntil: "networkidle" });
+
+const result = await page.evaluate(() => {
+  const list = document.querySelector(".agents-worklist");
+
+  if (!(list instanceof HTMLElement)) {
+    throw new Error("agents-worklist not found");
+  }
+
+  const style = window.getComputedStyle(list);
+  const template = style.gridTemplateColumns;
+  const count = template.split(" ").filter(Boolean).length;
+
+  return {
+    gridTemplateColumns: template,
+    columnCount: count
+  };
+});
+
+await page.screenshot({ path: "artifacts/agents-grid-check.png", fullPage: true });
+await browser.close();
+
+console.log(JSON.stringify(result, null, 2));

+ 68 - 0
scripts/reader-layout-check.mjs

@@ -0,0 +1,68 @@
+import { chromium } from "playwright-core";
+import fs from "node:fs/promises";
+import path from "node:path";
+
+const baseUrl = "http://127.0.0.1:3000/reader/lan-archive";
+const edgePath = "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe";
+const outDir = path.resolve("artifacts", "reader-layout");
+
+const cases = ["narrow", "medium", "wide"];
+
+await fs.mkdir(outDir, { recursive: true });
+
+const browser = await chromium.launch({
+  executablePath: edgePath,
+  headless: true
+});
+
+const page = await browser.newPage({
+  viewport: { width: 1600, height: 1200 },
+  deviceScaleFactor: 1
+});
+
+const results = [];
+
+for (const width of cases) {
+  await page.goto(`${baseUrl}?width=${width}`, { waitUntil: "networkidle" });
+
+  const metrics = await page.evaluate(() => {
+    const layout = document.querySelector(".reader-desktop-layout");
+    const shell = document.querySelector(".reader-qq-shell");
+    const left = document.querySelector(".reader-float--left");
+    const right = document.querySelector(".reader-float--right");
+
+    if (!(layout instanceof HTMLElement) || !(shell instanceof HTMLElement) || !(left instanceof HTMLElement) || !(right instanceof HTMLElement)) {
+      throw new Error("reader desktop layout nodes not found");
+    }
+
+    const layoutRect = layout.getBoundingClientRect();
+    const shellRect = shell.getBoundingClientRect();
+    const leftRect = left.getBoundingClientRect();
+    const rightRect = right.getBoundingClientRect();
+
+    return {
+      bodyWidth: document.body.clientWidth,
+      layoutLeft: layoutRect.left,
+      layoutWidth: layoutRect.width,
+      shellLeft: shellRect.left,
+      shellWidth: shellRect.width,
+      leftColumnLeft: leftRect.left,
+      leftColumnWidth: leftRect.width,
+      rightColumnLeft: rightRect.left,
+      rightColumnWidth: rightRect.width,
+      leftGapToShell: shellRect.left - (leftRect.left + leftRect.width),
+      rightGapToShell: rightRect.left - (shellRect.left + shellRect.width)
+    };
+  });
+
+  const imagePath = path.join(outDir, `${width}.png`);
+  await page.screenshot({ path: imagePath, fullPage: true });
+  results.push({ width, imagePath, metrics });
+}
+
+await browser.close();
+
+const reportPath = path.join(outDir, "report.json");
+await fs.writeFile(reportPath, JSON.stringify(results, null, 2), "utf8");
+
+console.log(JSON.stringify({ reportPath, results }, null, 2));

+ 113 - 0
scripts/sync-openclaw-agent-feed.mjs

@@ -0,0 +1,113 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+
+const allowedStatuses = new Set(["working", "idle", "warning", "offline"]);
+const allowedAvatarKinds = new Set([
+  "female-analyst",
+  "male-ops",
+  "female-researcher",
+  "male-dispatcher",
+  "female-observer",
+  "male-maintainer"
+]);
+
+function usage() {
+  console.error("Usage: node scripts/sync-openclaw-agent-feed.mjs <source-json> [target-json]");
+  process.exit(1);
+}
+
+function assertString(value, field, agentId) {
+  if (typeof value !== "string" || value.trim() === "") {
+    throw new Error(`Invalid ${field} for agent ${agentId}`);
+  }
+}
+
+function assertNumber(value, field, agentId) {
+  if (typeof value !== "number" || Number.isNaN(value)) {
+    throw new Error(`Invalid ${field} for agent ${agentId}`);
+  }
+}
+
+function normalizeAgent(agent) {
+  const agentId = typeof agent.id === "string" ? agent.id : "unknown";
+
+  assertString(agent.id, "id", agentId);
+  assertString(agent.name, "name", agentId);
+  assertString(agent.role, "role", agentId);
+  assertString(agent.host, "host", agentId);
+  assertString(agent.owner, "owner", agentId);
+  assertString(agent.currentTask, "currentTask", agentId);
+  assertString(agent.taskId, "taskId", agentId);
+  assertString(agent.taskStage, "taskStage", agentId);
+  assertString(agent.uptime, "uptime", agentId);
+  assertString(agent.lastHeartbeat, "lastHeartbeat", agentId);
+  assertString(agent.updatedAt, "updatedAt", agentId);
+  assertString(agent.lastOutput, "lastOutput", agentId);
+  assertNumber(agent.queueDepth, "queueDepth", agentId);
+  assertNumber(agent.todayCompleted, "todayCompleted", agentId);
+
+  if (!allowedStatuses.has(agent.status)) {
+    throw new Error(`Invalid status for agent ${agentId}`);
+  }
+
+  if (agent.avatarKind && !allowedAvatarKinds.has(agent.avatarKind)) {
+    throw new Error(`Invalid avatarKind for agent ${agentId}`);
+  }
+
+  return {
+    id: agent.id,
+    name: agent.name,
+    role: agent.role,
+    avatarKind: agent.avatarKind ?? "male-dispatcher",
+    status: agent.status,
+    statusLabel: typeof agent.statusLabel === "string" && agent.statusLabel ? agent.statusLabel : undefined,
+    host: agent.host,
+    owner: agent.owner,
+    currentTask: agent.currentTask,
+    taskId: agent.taskId,
+    taskStage: agent.taskStage,
+    queueDepth: agent.queueDepth,
+    todayCompleted: agent.todayCompleted,
+    uptime: agent.uptime,
+    lastHeartbeat: agent.lastHeartbeat,
+    updatedAt: agent.updatedAt,
+    lastOutput: agent.lastOutput,
+    lastError: typeof agent.lastError === "string" ? agent.lastError : undefined,
+    tags: Array.isArray(agent.tags) ? agent.tags.filter((tag) => typeof tag === "string") : []
+  };
+}
+
+async function main() {
+  const [, , sourceArg, targetArg] = process.argv;
+
+  if (!sourceArg) {
+    usage();
+  }
+
+  const sourcePath = path.resolve(sourceArg);
+  const targetPath = path.resolve(targetArg ?? path.join(process.cwd(), "storage", "agents", "openclaw-agents.json"));
+  const raw = await fs.readFile(sourcePath, "utf8");
+  const parsed = JSON.parse(raw);
+
+  if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.agents)) {
+    throw new Error("Feed JSON must contain an agents array");
+  }
+
+  const normalized = {
+    fetchedAt: typeof parsed.fetchedAt === "string" ? parsed.fetchedAt : new Date().toISOString(),
+    agents: parsed.agents.map(normalizeAgent)
+  };
+
+  await fs.mkdir(path.dirname(targetPath), { recursive: true });
+  const tempPath = `${targetPath}.tmp`;
+  await fs.writeFile(tempPath, JSON.stringify(normalized, null, 2), "utf8");
+  await fs.rename(tempPath, targetPath);
+
+  console.log(`OpenClaw agent feed synced to ${targetPath}`);
+  console.log(`Agents: ${normalized.agents.length}`);
+}
+
+main().catch((error) => {
+  console.error(error instanceof Error ? error.message : String(error));
+  process.exit(1);
+});

+ 7 - 0
site-plan.md

@@ -0,0 +1,7 @@
+# 方案入口
+
+项目方案文档在这里:
+
+- `docs/site-plan.md`
+
+如果你的文件树没及时刷新,直接打开上面的文件即可。

+ 28 - 0
tsconfig.json

@@ -0,0 +1,28 @@
+{
+  "compilerOptions": {
+    "target": "ES2017",
+    "lib": ["dom", "dom.iterable", "esnext"],
+    "allowJs": false,
+    "skipLibCheck": true,
+    "strict": true,
+    "noEmit": true,
+    "esModuleInterop": true,
+    "module": "esnext",
+    "moduleResolution": "bundler",
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "jsx": "preserve",
+    "incremental": true,
+    "plugins": [
+      {
+        "name": "next"
+      }
+    ],
+    "paths": {
+      "@/*": ["./*"]
+    }
+  },
+  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
+  "exclude": ["node_modules"]
+}
+

+ 38 - 0
types/agent.ts

@@ -0,0 +1,38 @@
+export type AgentStatus = "working" | "idle" | "warning" | "offline";
+
+export type AgentAvatarKind =
+  | "female-analyst"
+  | "male-ops"
+  | "female-researcher"
+  | "male-dispatcher"
+  | "female-observer"
+  | "male-maintainer";
+
+export type AgentProfile = {
+  id: string;
+  name: string;
+  role: string;
+  avatarKind: AgentAvatarKind;
+  status: AgentStatus;
+  statusLabel: string;
+  host: string;
+  owner: string;
+  currentTask: string;
+  taskId: string;
+  taskStage: string;
+  queueDepth: number;
+  todayCompleted: number;
+  uptime: string;
+  lastHeartbeat: string;
+  updatedAt: string;
+  lastOutput: string;
+  lastError?: string;
+  tags: string[];
+};
+
+export type AgentFeed = {
+  source: "demo" | "file";
+  sourceLabel: string;
+  fetchedAt: string;
+  agents: AgentProfile[];
+};

+ 21 - 0
types/better-sqlite3.d.ts

@@ -0,0 +1,21 @@
+declare module "better-sqlite3" {
+  type RunResult = {
+    changes: number;
+    lastInsertRowid: number | bigint;
+  };
+
+  type Statement<Params = unknown[], Row = unknown> = {
+    run(...params: Params extends unknown[] ? Params : [Params]): RunResult;
+    get(...params: Params extends unknown[] ? Params : [Params]): Row | undefined;
+    all(...params: Params extends unknown[] ? Params : [Params]): Row[];
+  };
+
+  class Database {
+    constructor(filename: string);
+    pragma(source: string): unknown;
+    exec(source: string): this;
+    prepare<Params = unknown[], Row = unknown>(source: string): Statement<Params, Row>;
+  }
+
+  export default Database;
+}

+ 17 - 0
types/book.ts

@@ -0,0 +1,17 @@
+export type Chapter = {
+  id: string;
+  title: string;
+  content: string[];
+};
+
+export type Book = {
+  id: string;
+  title: string;
+  author: string;
+  category: string;
+  description: string;
+  wordCount: string;
+  coverStyle: string;
+  chapters: Chapter[];
+};
+