feat: add offline sync and agent docs

This commit is contained in:
tianma committed 2026-06-27 01:37:36 +08:00
1 parent d3c5310b9d
commit 377b7b9f1d
9 files changed
+2427 -166

No files matched your search

+29 -1
View File
@@ -31,8 +31,9 @@ EdgeEver 是一个开源、自托管、Cloudflare-native 的现代笔记工作
- 多选合并笔记,原笔记软删除,资源关联迁移到新笔记。
- 多选移动笔记,笔记本支持拖拽排序和调整层级。
- PWA 可安装,支持静态应用壳离线打开和自动更新。
- 已有笔记支持离线编辑草稿和本地同步队列。
- 单用户登录,密码使用 PBKDF2-SHA256 hash。
- REST API-first,支持 API Token、MCP 和 CLI。
- REST API-first,支持 API Token、OpenAPI、MCP 和 CLI。
## 技术栈
@@ -104,6 +105,16 @@ content_markdown API、Agent、导入导出使用
content_text 搜索、摘要和索引使用
```
## API 文档
OpenAPI schema:
```text
https://你的域名/api/openapi.json
```
仓库内文件:[docs/openapi.json](docs/openapi.json)。
## MCP / CLI
先在 EdgeEver 左侧 **设置** 里创建 API Token。给只读 Agent 使用时建议只勾选 `read:notebooks`、`read:memos`、`read:tags`;需要写入、移动、合并或上传附件时再增加对应 `write:*` scope。
@@ -157,6 +168,23 @@ list_notebooks
list_tags
```
本地 stdio MCP 客户端可通过 bridge 连接远程 EdgeEver:
```sh
EDGEEVER_URL=https://你的域名 \
EDGEEVER_TOKEN=<api-token> \
bun run mcp:stdio
```
也可以复用 CLI profile:
```sh
bun run cli -- profile set prod --url https://你的域名 --token <api-token>
bun run mcp:stdio -- --profile prod
```
Claude Desktop / Cursor / Codex 这类本地客户端可把 command 配成 `bun`,args 配成仓库内 `scripts/edgeever-mcp-stdio.mjs` 的绝对路径,并通过环境变量传入 `EDGEEVER_URL` 和 `EDGEEVER_TOKEN`。
最小调用示例:
```sh
+19 -13
View File
@@ -1,5 +1,6 @@
import {
createExcerpt,
DEFAULT_MEMO_TITLE,
docToMarkdown,
docToText,
emptyDoc,
@@ -31,6 +32,7 @@ import type { Context } from "hono";
import { Hono } from "hono";
import { deleteCookie, getCookie, setCookie } from "hono/cookie";
import { cors } from "hono/cors";
import openApiSpec from "../../../docs/openapi.json";
type Bindings = {
DB: D1Database;
@@ -235,6 +237,8 @@ app.get("/api/health", (c) =>
})
);
app.get("/api/openapi.json", (c) => c.json(openApiSpec));
app.get("/api/v1/auth/session", async (c) => {
const authRequired = await isAuthRequired(c.env);
@@ -655,8 +659,8 @@ app.post("/api/v1/memos", zValidator("json", MemoCreateSchema), async (c) => {
const contentMarkdown = input.contentMarkdown ?? "";
const contentJson = markdownToDoc(contentMarkdown);
const contentText = docToText(contentJson);
const title = input.title || deriveTitle(contentText);
const excerpt = createExcerpt(contentText || title || "");
const title = normalizeMemoTitle(input.title);
const excerpt = createExcerpt(contentText);
const contentHash = await sha256(contentMarkdown + JSON.stringify(contentJson));
const id = createId("memo");
const now = isoNow();
@@ -787,8 +791,8 @@ app.post("/api/v1/memos/:id/revisions/:revisionId/restore", async (c) => {
const contentJson = parseDoc(revision.content_json);
const contentMarkdown = revision.content_markdown || docToMarkdown(contentJson);
const contentText = revision.content_text || docToText(contentJson);
const title = revision.title ?? deriveTitle(contentText);
const excerpt = createExcerpt(contentText || title || "");
const title = normalizeMemoTitle(revision.title);
const excerpt = createExcerpt(contentText);
const contentHash = await sha256(contentMarkdown + JSON.stringify(contentJson));
const nextRevision = current.revision + 1;
const now = isoNow();
@@ -1039,9 +1043,10 @@ app.patch("/api/v1/memos/:id", zValidator("json", MemoUpdateSchema), async (c) =
const contentMarkdown =
input.contentMarkdown !== undefined ? input.contentMarkdown : docToMarkdown(contentJson);
const contentText = docToText(contentJson);
const title = input.title ?? current.title ?? deriveTitle(contentText);
const title =
input.title !== undefined ? normalizeMemoTitle(input.title) : normalizeMemoTitle(current.title);
const tags = input.tags === undefined ? parseJsonArray(current.tags_json) : normalizeTags(input.tags);
const excerpt = createExcerpt(contentText || title || "");
const excerpt = createExcerpt(contentText);
const notebookId = input.notebookId ?? current.notebook_id;
const nextRevision = current.revision + 1;
const contentHash = await sha256(contentMarkdown + JSON.stringify(contentJson));
@@ -2629,8 +2634,8 @@ const createMemoRecord = async (
const contentMarkdown = input.contentMarkdown ?? "";
const contentJson = markdownToDoc(contentMarkdown);
const contentText = docToText(contentJson);
const title = input.title || deriveTitle(contentText);
const excerpt = createExcerpt(contentText || title || "");
const title = normalizeMemoTitle(input.title);
const excerpt = createExcerpt(contentText);
const contentHash = await sha256(contentMarkdown + JSON.stringify(contentJson));
const id = createId("memo");
const now = isoNow();
@@ -2698,9 +2703,10 @@ const updateMemoRecord = async (
const contentMarkdown =
input.contentMarkdown !== undefined ? input.contentMarkdown : docToMarkdown(contentJson);
const contentText = docToText(contentJson);
const title = input.title ?? current.title ?? deriveTitle(contentText);
const title =
input.title !== undefined ? normalizeMemoTitle(input.title) : normalizeMemoTitle(current.title);
const tags = input.tags === undefined ? parseJsonArray(current.tags_json) : normalizeTags(input.tags);
const excerpt = createExcerpt(contentText || title || "");
const excerpt = createExcerpt(contentText);
const notebookId = input.notebookId ?? current.notebook_id;
const nextRevision = current.revision + 1;
const contentHash = await sha256(contentMarkdown + JSON.stringify(contentJson));
@@ -2920,9 +2926,9 @@ const slugify = (value: string) =>
.replace(/^-+|-+$/g, "")
.slice(0, 80);
const deriveTitle = (text: string) => {
const title = text.trim().split(/\s+/).slice(0, 10).join(" ");
return title || "Untitled memo";
const normalizeMemoTitle = (value: string | null | undefined) => {
const title = value?.trim();
return title || DEFAULT_MEMO_TITLE;
};
const clampNumber = (value: number, min: number, max: number) => {
+474 -152
View File
@@ -1,4 +1,5 @@
import {
DEFAULT_MEMO_TITLE,
docToMarkdown,
type ApiToken,
type AuthSession,
@@ -20,7 +21,10 @@ import {
CheckSquare,
ChevronDown,
ChevronRight,
CheckCircle2,
Code2,
AlertTriangle,
CloudOff,
ExternalLink,
File as FileIcon,
FilePlus2,
@@ -44,6 +48,7 @@ import {
Plus,
Quote,
Redo2,
RefreshCw,
RotateCcw,
Save,
Search,
@@ -69,7 +74,16 @@ import {
} from "react";
import { api } from "@/lib/api";
import { compressImageForUpload } from "@/lib/image-compression";
import { localDb } from "@/lib/local-db";
import { localDb, type MemoUpdateSyncPayload } from "@/lib/local-db";
import {
emptySyncQueueSummary,
getMemoUpdateQueueId,
observeSyncQueue,
queueMemoUpdate,
shouldQueueMemoSaveError,
syncQueuedChanges,
type SyncQueueSummary,
} from "@/lib/sync-queue";
import { buildNotebookTree, cn, formatDateTime, parseTagsText, type NotebookNode } from "@/lib/utils";
import { Button } from "@/components/ui/button";
@@ -169,6 +183,32 @@ const WorkspaceApp = ({
const [tagsOpen, setTagsOpen] = useState(false);
const [settingsOpen, setSettingsOpen] = useState(false);
const [search, setSearch] = useState("");
const [syncSummary, setSyncSummary] = useState<SyncQueueSummary>(emptySyncQueueSummary);
const [isOnline, setIsOnline] = useState(() => (typeof navigator === "undefined" ? true : navigator.onLine));
const [isSyncingQueuedChanges, setIsSyncingQueuedChanges] = useState(false);
const runQueuedSync = useCallback(async () => {
if (typeof navigator !== "undefined" && !navigator.onLine) {
setIsOnline(false);
return;
}
setIsSyncingQueuedChanges(true);
try {
await syncQueuedChanges({
onSynced: async (memo) => {
queryClient.setQueryData(["memo", memo.id], { memo });
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["memos"] }),
queryClient.invalidateQueries({ queryKey: ["memo", memo.id] }),
]);
},
});
} finally {
setIsSyncingQueuedChanges(false);
}
}, [queryClient]);
const notebooksQuery = useQuery({
queryKey: ["notebooks"],
@@ -211,6 +251,40 @@ const WorkspaceApp = ({
writeImageCompressionPreference(imageCompressionEnabled);
}, [imageCompressionEnabled]);
useEffect(() => observeSyncQueue(setSyncSummary), []);
useEffect(() => {
const updateOnlineState = () => {
const online = navigator.onLine;
setIsOnline(online);
if (online) {
void runQueuedSync();
}
};
window.addEventListener("online", updateOnlineState);
window.addEventListener("offline", updateOnlineState);
updateOnlineState();
return () => {
window.removeEventListener("online", updateOnlineState);
window.removeEventListener("offline", updateOnlineState);
};
}, [runQueuedSync]);
useEffect(() => {
if (syncSummary.total === 0) {
return;
}
const timer = window.setInterval(() => {
void runQueuedSync();
}, 15_000);
return () => window.clearInterval(timer);
}, [runQueuedSync, syncSummary.total]);
const memosQuery = useQuery({
queryKey: ["memos", memoView, selectedNotebookId, search],
queryFn: () =>
@@ -370,7 +444,7 @@ const WorkspaceApp = ({
createMemoMutation.mutate({
notebookId: selectedNotebookId,
title: "Untitled memo",
title: DEFAULT_MEMO_TITLE,
contentMarkdown: "",
tags: [],
});
@@ -453,6 +527,10 @@ const WorkspaceApp = ({
isLoggingOut={isLoggingOut}
imageCompressionEnabled={imageCompressionEnabled}
onImageCompressionChange={setImageCompressionEnabled}
syncSummary={syncSummary}
isOnline={isOnline}
isSyncingQueuedChanges={isSyncingQueuedChanges}
onSyncQueuedChanges={() => void runQueuedSync()}
onOpenAssets={() => setAssetsOpen(true)}
onOpenTags={() => setTagsOpen(true)}
onOpenSettings={() => setSettingsOpen(true)}
@@ -699,6 +777,10 @@ const NotebookPane = ({
isLoggingOut,
imageCompressionEnabled,
onImageCompressionChange,
syncSummary,
isOnline,
isSyncingQueuedChanges,
onSyncQueuedChanges,
onOpenAssets,
onOpenTags,
onOpenSettings,
@@ -719,6 +801,10 @@ const NotebookPane = ({
isLoggingOut: boolean;
imageCompressionEnabled: boolean;
onImageCompressionChange: (enabled: boolean) => void;
syncSummary: SyncQueueSummary;
isOnline: boolean;
isSyncingQueuedChanges: boolean;
onSyncQueuedChanges: () => void;
onOpenAssets: () => void;
onOpenTags: () => void;
onOpenSettings: () => void;
@@ -772,6 +858,12 @@ const NotebookPane = ({
</div>
<footer className="border-t border-emerald-100 px-4 pb-[max(0.75rem,env(safe-area-inset-bottom))] pt-3">
<SyncStatusBar
summary={syncSummary}
isOnline={isOnline}
isSyncing={isSyncingQueuedChanges}
onSyncNow={onSyncQueuedChanges}
/>
<label className="mb-3 flex min-h-10 items-center justify-between gap-3 rounded-md border border-emerald-100 bg-emerald-50/70 px-3 py-2">
<span className="min-w-0 text-sm font-medium text-slate-700">压缩图片</span>
<input
@@ -806,6 +898,78 @@ const NotebookPane = ({
);
};
const SyncStatusBar = ({
summary,
isOnline,
isSyncing,
onSyncNow,
}: {
summary: SyncQueueSummary;
isOnline: boolean;
isSyncing: boolean;
onSyncNow: () => void;
}) => {
const hasQueuedWork = summary.total > 0;
const label = getSyncStatusLabel(summary, isOnline, isSyncing);
const statusClassName = !isOnline
? "border-slate-200 bg-slate-50 text-slate-600"
: summary.conflict > 0
? "border-amber-200 bg-amber-50 text-amber-800"
: hasQueuedWork
? "border-emerald-200 bg-emerald-50 text-emerald-800"
: "border-emerald-100 bg-white text-slate-500";
return (
<div className={cn("mb-3 flex min-h-10 items-center gap-2 rounded-md border px-3 py-2", statusClassName)}>
{!isOnline ? (
<CloudOff className="h-4 w-4 shrink-0" />
) : summary.conflict > 0 ? (
<AlertTriangle className="h-4 w-4 shrink-0" />
) : hasQueuedWork || isSyncing ? (
<RefreshCw className={cn("h-4 w-4 shrink-0", isSyncing && "animate-spin")} />
) : (
<CheckCircle2 className="h-4 w-4 shrink-0" />
)}
<span className="min-w-0 flex-1 truncate text-xs font-medium">{label}</span>
{hasQueuedWork ? (
<button
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md hover:bg-white/70 disabled:opacity-50"
type="button"
title="立即同步"
disabled={!isOnline || isSyncing}
onClick={onSyncNow}
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
) : null}
</div>
);
};
const getSyncStatusLabel = (summary: SyncQueueSummary, isOnline: boolean, isSyncing: boolean) => {
if (!isOnline) {
return summary.total > 0 ? `离线,${summary.total} 项待同步` : "离线";
}
if (isSyncing || summary.syncing > 0) {
return "同步中";
}
if (summary.conflict > 0) {
return `${summary.conflict} 项同步冲突`;
}
if (summary.error > 0) {
return `${summary.error} 项等待重试`;
}
if (summary.pending > 0) {
return `${summary.pending} 项待同步`;
}
return "已同步";
};
const NotebookTreeItem = ({
node,
depth,
@@ -991,6 +1155,7 @@ const MemoListPane = ({
onMoveSelectedMemos: (notebookId: string) => void;
}) => {
const [moveTargetNotebookId, setMoveTargetNotebookId] = useState(notebook?.id ?? notebooks[0]?.id ?? "");
const memoGroups = useMemo(() => groupMemosByMonth(memos), [memos]);
useEffect(() => {
if (notebook?.id) {
@@ -999,125 +1164,159 @@ const MemoListPane = ({
}, [notebook?.id]);
return (
<div className="relative flex h-full min-h-0 flex-col">
<header className="border-b border-emerald-100 bg-white px-4 pb-3 pt-[max(0.75rem,env(safe-area-inset-top))] lg:py-3">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<Button className="lg:hidden" size="icon" variant="ghost" title="打开笔记本" onClick={onBackToNotebooks}>
<PanelLeft className="h-4 w-4" />
</Button>
<div className="min-w-0">
<div className="truncate text-lg font-semibold text-slate-950 lg:text-sm">
{view === "trash" ? "回收站" : notebook?.name ?? "全部笔记"}
</div>
<div className="text-xs text-slate-500">
{memos.length} {view === "trash" ? "trashed" : "memos"}
<div className="relative flex h-full min-h-0 flex-col">
<header className="border-b border-emerald-100 bg-white px-4 pb-3 pt-[max(0.75rem,env(safe-area-inset-top))] lg:py-3">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<Button className="lg:hidden" size="icon" variant="ghost" title="打开笔记本" onClick={onBackToNotebooks}>
<PanelLeft className="h-4 w-4" />
</Button>
<div className="min-w-0">
<div className="truncate text-lg font-semibold text-slate-950 lg:text-sm">
{view === "trash" ? "回收站" : notebook?.name ?? "全部笔记"}
</div>
<div className="text-xs text-slate-500">
{memos.length} {view === "trash" ? "条已删除" : "条笔记"}
</div>
</div>
</div>
<Button
className="hidden lg:inline-flex"
size="icon"
variant="solid"
title="新建笔记"
onClick={onCreateMemo}
disabled={!notebook || isCreating || view === "trash"}
>
<FilePlus2 className="h-4 w-4" />
</Button>
</div>
<Button
className="hidden lg:inline-flex"
size="icon"
variant="solid"
<label className="flex h-9 items-center gap-2 rounded-md border border-emerald-100 bg-emerald-50/70 px-3 text-sm text-slate-500">
<Search className="h-4 w-4" />
<input
value={search}
onChange={(event) => onSearch(event.target.value)}
className="min-w-0 flex-1 bg-transparent text-slate-900 outline-none placeholder:text-slate-400"
placeholder="搜索笔记"
/>
</label>
</header>
<div className="relative min-h-0 flex-1 overflow-y-auto p-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
{selectedMemoIds.size > 0 ? (
<div className="sticky top-0 z-10 mb-3 flex flex-wrap items-center justify-between gap-2 rounded-md border border-emerald-100 bg-white px-3 py-2 shadow-panel">
<div className="flex items-center gap-2 text-sm font-medium">
<CheckSquare className="h-4 w-4 text-emerald-700" />
{selectedMemoIds.size} selected
</div>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<select
className="h-8 max-w-40 rounded-md border border-emerald-100 bg-emerald-50/70 px-2 text-xs text-emerald-900 outline-none disabled:opacity-50"
value={moveTargetNotebookId}
disabled={view === "trash" || notebooks.length === 0 || isMoving}
onChange={(event) => setMoveTargetNotebookId(event.target.value)}
title="移动到笔记本"
>
{notebooks.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
<Button
size="sm"
variant="soft"
onClick={() => onMoveSelectedMemos(moveTargetNotebookId)}
disabled={!moveTargetNotebookId || isMoving || view === "trash"}
>
<Folder className="h-4 w-4" />
移动
</Button>
<Button
size="sm"
variant="solid"
onClick={onMerge}
disabled={selectedMemoIds.size < 2 || isMerging || view === "trash"}
>
<Merge className="h-4 w-4" />
合并
</Button>
</div>
</div>
) : null}
{isLoading ? (
<div className="px-2 py-4 text-sm text-slate-500">加载中</div>
) : memos.length === 0 ? (
<div className="rounded-md border border-dashed border-slate-300 bg-white px-4 py-8 text-center text-sm text-slate-500">
{view === "trash" ? "回收站为空" : "暂无笔记"}
</div>
) : (
<div className="overflow-hidden rounded-sm border-y border-slate-200 bg-white">
{memoGroups.map((group) => (
<section key={group.key}>
<div className="sticky top-0 z-[4] flex h-9 items-center justify-between border-b border-slate-200 bg-white/95 px-4 text-sm font-semibold text-slate-500 backdrop-blur">
<span>{group.label}</span>
<span>{group.items.length}</span>
</div>
<div>
{group.items.map((memo) => (
<MemoCard
key={memo.id}
memo={memo}
selected={memo.id === selectedMemoId}
checked={selectedMemoIds.has(memo.id)}
multiSelectKeyDown={multiSelectKeyDown}
onOpen={() => onOpenMemo(memo.id)}
onToggle={() => onToggleMemo(memo.id)}
/>
))}
</div>
</section>
))}
</div>
)}
</div>
{selectedMemoIds.size === 0 ? (
<button
className="absolute bottom-[max(1rem,env(safe-area-inset-bottom))] right-4 flex h-14 items-center gap-2 rounded-full border border-emerald-200 bg-emerald-100 px-5 text-sm font-semibold text-emerald-950 shadow-panel transition hover:bg-emerald-200 disabled:opacity-50 lg:hidden"
title="新建笔记"
onClick={onCreateMemo}
disabled={!notebook || isCreating || view === "trash"}
disabled={!notebook || isCreating}
hidden={view === "trash"}
>
<FilePlus2 className="h-4 w-4" />
</Button>
</div>
<label className="flex h-9 items-center gap-2 rounded-md border border-emerald-100 bg-emerald-50/70 px-3 text-sm text-slate-500">
<Search className="h-4 w-4" />
<input
value={search}
onChange={(event) => onSearch(event.target.value)}
className="min-w-0 flex-1 bg-transparent text-slate-900 outline-none placeholder:text-slate-400"
placeholder="Search memos"
/>
</label>
</header>
<div className="relative min-h-0 flex-1 overflow-y-auto p-3 pb-[max(0.75rem,env(safe-area-inset-bottom))]">
{selectedMemoIds.size > 0 ? (
<div className="sticky top-0 z-10 mb-3 flex flex-wrap items-center justify-between gap-2 rounded-md border border-emerald-100 bg-white px-3 py-2 shadow-panel">
<div className="flex items-center gap-2 text-sm font-medium">
<CheckSquare className="h-4 w-4 text-emerald-700" />
{selectedMemoIds.size} selected
</div>
<div className="flex min-w-0 flex-wrap items-center gap-2">
<select
className="h-8 max-w-40 rounded-md border border-emerald-100 bg-emerald-50/70 px-2 text-xs text-emerald-900 outline-none disabled:opacity-50"
value={moveTargetNotebookId}
disabled={view === "trash" || notebooks.length === 0 || isMoving}
onChange={(event) => setMoveTargetNotebookId(event.target.value)}
title="移动到笔记本"
>
{notebooks.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
<Button
size="sm"
variant="soft"
onClick={() => onMoveSelectedMemos(moveTargetNotebookId)}
disabled={!moveTargetNotebookId || isMoving || view === "trash"}
>
<Folder className="h-4 w-4" />
移动
</Button>
<Button
size="sm"
variant="solid"
onClick={onMerge}
disabled={selectedMemoIds.size < 2 || isMerging || view === "trash"}
>
<Merge className="h-4 w-4" />
合并
</Button>
</div>
</div>
<FilePlus2 className="h-5 w-5" />
新建
</button>
) : null}
{isLoading ? (
<div className="px-2 py-4 text-sm text-slate-500">加载中</div>
) : memos.length === 0 ? (
<div className="rounded-md border border-dashed border-slate-300 bg-white px-4 py-8 text-center text-sm text-slate-500">
{view === "trash" ? "回收站为空" : "No memos"}
</div>
) : (
<div className="space-y-2">
{memos.map((memo) => (
<MemoCard
key={memo.id}
memo={memo}
selected={memo.id === selectedMemoId}
checked={selectedMemoIds.has(memo.id)}
multiSelectKeyDown={multiSelectKeyDown}
onOpen={() => onOpenMemo(memo.id)}
onToggle={() => onToggleMemo(memo.id)}
/>
))}
</div>
)}
</div>
{selectedMemoIds.size === 0 ? (
<button
className="absolute bottom-[max(1rem,env(safe-area-inset-bottom))] right-4 flex h-14 items-center gap-2 rounded-full border border-emerald-200 bg-emerald-100 px-5 text-sm font-semibold text-emerald-950 shadow-panel transition hover:bg-emerald-200 disabled:opacity-50 lg:hidden"
title="新建笔记"
onClick={onCreateMemo}
disabled={!notebook || isCreating}
hidden={view === "trash"}
>
<FilePlus2 className="h-5 w-5" />
新建
</button>
) : null}
</div>
);
};
const groupMemosByMonth = (memos: MemoSummary[]) => {
const groups: Array<{ key: string; label: string; items: MemoSummary[] }> = [];
for (const memo of memos) {
const date = new Date(memo.updatedAt);
const key = Number.isNaN(date.getTime())
? "unknown"
: `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
const label = Number.isNaN(date.getTime())
? "未知时间"
: new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "long" }).format(date);
const current = groups[groups.length - 1];
if (current?.key === key) {
current.items.push(memo);
continue;
}
groups.push({ key, label, items: [memo] });
}
return groups;
};
const MemoCard = ({
memo,
selected,
@@ -1134,6 +1333,8 @@ const MemoCard = ({
onToggle: () => void;
}) => {
const handledModifierPointerRef = useRef(false);
const memoTitle = getMemoTitle(memo.title);
const memoExcerpt = memo.excerpt.trim() || "空笔记";
const shouldToggleSelection = (event: MouseEvent<HTMLElement>) =>
event.ctrlKey || event.metaKey || multiSelectKeyDown;
@@ -1191,32 +1392,31 @@ const MemoCard = ({
return (
<article
className={cn(
"rounded-md border bg-white p-3 transition",
selected ? "border-emerald-300 shadow-panel" : "border-emerald-100 hover:border-emerald-200",
checked && "border-emerald-300 bg-emerald-50/70"
"border-b border-slate-200 transition last:border-b-0",
selected ? "bg-slate-200" : checked ? "bg-emerald-50/80" : "bg-white hover:bg-slate-50"
)}
>
<div className="flex items-start gap-3">
<div className="flex min-h-[128px] items-start">
<input
type="checkbox"
checked={checked}
onChange={onToggle}
className="mt-1 h-5 w-5 shrink-0 rounded border-emerald-300 text-emerald-600"
aria-label={`选择 ${memo.title ?? memo.excerpt}`}
className="ml-4 mt-5 h-4 w-4 shrink-0 rounded border-slate-300 text-emerald-600"
aria-label={`选择 ${memoTitle}`}
/>
<button
className={cn("min-w-0 flex-1 text-left", multiSelectKeyDown && "cursor-copy")}
className={cn("min-w-0 flex-1 px-4 py-4 text-left", multiSelectKeyDown && "cursor-copy")}
onMouseDown={handleMouseDown}
onClick={handleClick}
onContextMenu={handleContextMenu}
title="Ctrl/Cmd 点击切换选择"
>
<div className="mb-1 truncate text-sm font-semibold text-slate-950">{memo.title || "Untitled memo"}</div>
<div className="line-clamp-2 min-h-[40px] text-sm leading-5 text-slate-600">{memo.excerpt || "Empty memo"}</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<time className="text-xs text-slate-400">{formatDateTime(memo.updatedAt)}</time>
<div className="mb-2 truncate text-base font-semibold leading-6 text-slate-900">{memoTitle}</div>
<div className="line-clamp-2 min-h-10 text-[15px] leading-5 text-slate-600">{memoExcerpt}</div>
<div className="mt-5 flex flex-wrap items-center gap-2">
<time className="text-sm text-slate-500">{formatMemoPreviewDate(memo.updatedAt)}</time>
{memo.tags.slice(0, 3).map((tag) => (
<span key={tag} className="rounded bg-emerald-50 px-1.5 py-0.5 text-xs font-medium text-emerald-700">
<span key={tag} className="rounded-sm bg-emerald-50 px-1.5 py-0.5 text-xs font-medium text-emerald-700">
#{tag}
</span>
))}
@@ -1227,6 +1427,34 @@ const MemoCard = ({
);
};
const getMemoTitle = (title: string | null | undefined) => title?.trim() || DEFAULT_MEMO_TITLE;
const formatMemoPreviewDate = (value: string) => {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return "";
}
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const memoDay = new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
if (memoDay === today) {
return new Intl.DateTimeFormat("zh-CN", { hour: "2-digit", minute: "2-digit" }).format(date);
}
if (memoDay === today - 24 * 60 * 60 * 1000) {
return "昨天";
}
return new Intl.DateTimeFormat("zh-CN", {
year: "numeric",
month: "numeric",
day: "numeric",
}).format(date);
};
const SUPPORTED_PASTE_IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/gif", "image/webp", "image/avif"]);
const getImageFilesFromDataTransfer = (dataTransfer: DataTransfer | null) => {
@@ -1662,7 +1890,7 @@ const RevisionHistoryDialog = ({
<History className="h-4 w-4 text-emerald-700" />
版本历史
</div>
<div className="mt-1 truncate text-xs text-slate-500">{memo.title || "Untitled memo"}</div>
<div className="mt-1 truncate text-xs text-slate-500">{getMemoTitle(memo.title)}</div>
</div>
<Button size="icon" variant="ghost" title="关闭" onClick={onClose}>
<X className="h-4 w-4" />
@@ -1740,7 +1968,7 @@ const RevisionPreview = ({ title, markdown }: { title: string; markdown: string
<div className="min-h-[260px] border-b border-emerald-100 p-4 sm:border-b-0 sm:border-r">
<div className="mb-3 text-xs font-semibold uppercase text-slate-500">{title}</div>
<pre className="max-h-[54dvh] overflow-auto whitespace-pre-wrap break-words rounded-md border border-emerald-100 bg-emerald-50/30 p-3 text-sm leading-6 text-slate-700">
{markdown || "Empty memo"}
{markdown || "空笔记"}
</pre>
</div>
);
@@ -1772,6 +2000,32 @@ const formatRevisionActor = (actor: string) => {
return actor || "system";
};
const syncStatusToSaveState = (status: "pending" | "syncing" | "conflict" | "error") => {
if (status === "conflict") {
return "conflict";
}
if (status === "syncing") {
return "saving";
}
return "queued";
};
class MemoSaveRequestError extends Error {
originalError: unknown;
payload: MemoUpdateSyncPayload;
tagsText: string;
constructor(originalError: unknown, payload: MemoUpdateSyncPayload, tagsText: string) {
super(originalError instanceof Error ? originalError.message : "Memo save failed");
this.name = "MemoSaveRequestError";
this.originalError = originalError;
this.payload = payload;
this.tagsText = tagsText;
}
}
const EditorToolbar = ({ editor, readOnly }: { editor: Editor | null; readOnly: boolean }) => {
const disabled = readOnly || !editor;
const blockValue = getActiveBlockValue(editor);
@@ -2002,7 +2256,7 @@ const EditorPane = ({
const queryClient = useQueryClient();
const [title, setTitle] = useState("");
const [tagsText, setTagsText] = useState("");
const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error" | "conflict">("idle");
const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "queued" | "error" | "conflict">("idle");
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [dirtyVersion, setDirtyVersion] = useState(0);
const [, setEditorStateVersion] = useState(0);
@@ -2181,6 +2435,7 @@ const EditorPane = ({
useEffect(() => {
const currentEditor = editorRef.current;
let cancelled = false;
if (!memo) {
memoRef.current = null;
@@ -2202,21 +2457,46 @@ const EditorPane = ({
return;
}
hydratingRef.current = true;
editingMemoIdRef.current = memo.id;
hasUnsavedChangesRef.current = false;
setHasUnsavedChanges(false);
setSaveState("idle");
setTitle(memo.title ?? "");
setTagsText(memo.tags.join(", "));
void (async () => {
const [draft, queuedUpdate] = memo.isDeleted
? [null, null]
: await Promise.all([
localDb.drafts.get(memo.id),
localDb.syncQueue.get(getMemoUpdateQueueId(memo.id)),
]);
if (currentEditor) {
currentEditor.commands.setContent(memo.contentJson);
}
if (cancelled) {
return;
}
window.setTimeout(() => {
hydratingRef.current = false;
}, 0);
const draftUpdatedAt = draft ? Date.parse(draft.updatedAt) : 0;
const remoteUpdatedAt = Date.parse(memo.updatedAt);
const useDraft = Boolean(draft && (queuedUpdate || draftUpdatedAt >= remoteUpdatedAt));
const nextTitle = useDraft && draft ? draft.title : memo.title ?? "";
const nextTagsText = useDraft && draft ? draft.tagsText : memo.tags.join(", ");
const nextContent = useDraft && draft ? draft.contentJson : memo.contentJson;
const nextHasUnsavedChanges = Boolean(useDraft && !queuedUpdate);
hydratingRef.current = true;
editingMemoIdRef.current = memo.id;
hasUnsavedChangesRef.current = nextHasUnsavedChanges;
setHasUnsavedChanges(nextHasUnsavedChanges);
setSaveState(queuedUpdate ? syncStatusToSaveState(queuedUpdate.status) : "idle");
setTitle(nextTitle);
setTagsText(nextTagsText);
if (currentEditor) {
currentEditor.commands.setContent(nextContent);
}
window.setTimeout(() => {
hydratingRef.current = false;
}, 0);
})();
return () => {
cancelled = true;
};
}, [isTrashView, memo, editor]);
useEffect(() => {
@@ -2258,12 +2538,26 @@ const EditorPane = ({
throw new Error("Editor is not ready");
}
const data = await api.updateMemo(currentMemo.id, {
const contentJson = currentEditor.getJSON() as TiptapDoc;
const payload: MemoUpdateSyncPayload = {
memoId: currentMemo.id,
expectedRevision: currentMemo.revision,
title,
contentJson: currentEditor.getJSON() as TiptapDoc,
contentJson,
tags: parseTagsText(tagsText),
});
};
let data;
try {
data = await api.updateMemo(currentMemo.id, {
expectedRevision: payload.expectedRevision,
title: payload.title,
contentJson: payload.contentJson,
tags: payload.tags,
});
} catch (error) {
throw new MemoSaveRequestError(error, payload, tagsText);
}
return { memo: data.memo, snapshot };
},
@@ -2286,9 +2580,35 @@ const EditorPane = ({
setHasUnsavedChanges(true);
setSaveState("idle");
},
onError: (error) => {
const code = error && typeof error === "object" && "code" in error ? String(error.code) : null;
setSaveState(code === "revision_conflict" ? "conflict" : "error");
onError: async (error) => {
const sourceError = error instanceof MemoSaveRequestError ? error.originalError : error;
const code =
sourceError && typeof sourceError === "object" && "code" in sourceError
? String(sourceError.code)
: null;
if (code === "revision_conflict") {
setSaveState("conflict");
return;
}
if (error instanceof MemoSaveRequestError && shouldQueueMemoSaveError(sourceError)) {
await queueMemoUpdate(error.payload);
await localDb.drafts.put({
memoId: error.payload.memoId,
title: error.payload.title,
tagsText: error.tagsText,
contentJson: error.payload.contentJson,
updatedAt: new Date().toISOString(),
});
hasUnsavedChangesRef.current = false;
setHasUnsavedChanges(false);
setSaveState("queued");
return;
}
setSaveState("error");
},
});
@@ -2332,13 +2652,15 @@ const EditorPane = ({
? "保存中"
: saveState === "saved"
? "已保存"
: saveState === "conflict"
? "有冲突"
: saveState === "error"
? "保存失败"
: hasUnsavedChanges
? "未保存"
: "已保存";
: saveState === "queued"
? "待同步"
: saveState === "conflict"
? "有冲突"
: saveState === "error"
? "保存失败"
: hasUnsavedChanges
? "未保存"
: "已保存";
return (
<div className="flex h-full min-w-0 flex-col">
@@ -2437,7 +2759,7 @@ const EditorPane = ({
markDirty();
}}
className="block w-full border-0 bg-transparent text-2xl font-semibold leading-tight text-slate-950 outline-none placeholder:text-slate-300 sm:text-3xl"
placeholder="Untitled memo"
placeholder={DEFAULT_MEMO_TITLE}
/>
<label className="flex h-8 items-center gap-2 text-sm text-slate-500">
<Tags className="h-4 w-4" />
+26
View File
@@ -9,14 +9,40 @@ export type LocalDraft = {
updatedAt: string;
};
export type MemoUpdateSyncPayload = {
memoId: string;
expectedRevision: number;
title: string;
contentJson: TiptapDoc;
tags: string[];
};
export type SyncQueueItem = {
id: string;
kind: "memo.update";
memoId: string;
status: "pending" | "syncing" | "conflict" | "error";
payload: MemoUpdateSyncPayload;
attemptCount: number;
lastError: string | null;
nextAttemptAt: string | null;
createdAt: string;
updatedAt: string;
};
class EdgeEverLocalDb extends Dexie {
drafts!: Table<LocalDraft, string>;
syncQueue!: Table<SyncQueueItem, string>;
constructor() {
super("edgeever-local");
this.version(1).stores({
drafts: "memoId, updatedAt",
});
this.version(2).stores({
drafts: "memoId, updatedAt",
syncQueue: "id, memoId, status, updatedAt, nextAttemptAt",
});
}
}
+165
View File
@@ -0,0 +1,165 @@
import type { MemoDetail, TiptapDoc } from "@edgeever/shared";
import { liveQuery } from "dexie";
import { ApiRequestError, api } from "@/lib/api";
import { localDb, type MemoUpdateSyncPayload, type SyncQueueItem } from "@/lib/local-db";
export type SyncQueueSummary = {
total: number;
pending: number;
syncing: number;
conflict: number;
error: number;
};
export type SyncRunResult = {
attempted: number;
synced: number;
failed: number;
conflicted: number;
};
export const emptySyncQueueSummary = (): SyncQueueSummary => ({
total: 0,
pending: 0,
syncing: 0,
conflict: 0,
error: 0,
});
export const getMemoUpdateQueueId = (memoId: string) => `memo.update:${memoId}`;
export const queueMemoUpdate = async (payload: MemoUpdateSyncPayload) => {
const id = getMemoUpdateQueueId(payload.memoId);
const now = new Date().toISOString();
const existing = await localDb.syncQueue.get(id);
await localDb.syncQueue.put({
id,
kind: "memo.update",
memoId: payload.memoId,
status: "pending",
payload,
attemptCount: existing?.attemptCount ?? 0,
lastError: null,
nextAttemptAt: null,
createdAt: existing?.createdAt ?? now,
updatedAt: now,
});
};
export const observeSyncQueue = (onChange: (summary: SyncQueueSummary) => void) => {
const subscription = liveQuery(async () => summarizeSyncQueue(await localDb.syncQueue.toArray())).subscribe({
next: onChange,
error: () => onChange(emptySyncQueueSummary()),
});
return () => subscription.unsubscribe();
};
export const syncQueuedChanges = async (options: {
onSynced?: (memo: MemoDetail) => void | Promise<void>;
} = {}): Promise<SyncRunResult> => {
const result: SyncRunResult = {
attempted: 0,
synced: 0,
failed: 0,
conflicted: 0,
};
if (typeof navigator !== "undefined" && !navigator.onLine) {
return result;
}
const now = new Date();
const items = (await localDb.syncQueue.where("status").anyOf("pending", "error").toArray())
.filter((item) => !item.nextAttemptAt || new Date(item.nextAttemptAt) <= now)
.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
for (const item of items) {
result.attempted += 1;
await localDb.syncQueue.update(item.id, {
status: "syncing",
updatedAt: new Date().toISOString(),
});
try {
const memo = await syncQueueItem(item);
await localDb.syncQueue.delete(item.id);
await localDb.drafts.delete(item.memoId);
await options.onSynced?.(memo);
result.synced += 1;
} catch (error) {
const status = isRevisionConflict(error) ? "conflict" : "error";
const attemptCount = item.attemptCount + 1;
await localDb.syncQueue.update(item.id, {
status,
attemptCount,
lastError: getErrorMessage(error),
nextAttemptAt: status === "error" ? nextRetryAt(attemptCount) : null,
updatedAt: new Date().toISOString(),
});
if (status === "conflict") {
result.conflicted += 1;
} else {
result.failed += 1;
}
}
}
return result;
};
export const shouldQueueMemoSaveError = (error: unknown) => {
if (typeof navigator !== "undefined" && !navigator.onLine) {
return true;
}
if (error instanceof ApiRequestError) {
return error.status === 408 || error.status === 429 || error.status >= 500;
}
return error instanceof TypeError;
};
const syncQueueItem = async (item: SyncQueueItem): Promise<MemoDetail> => {
if (item.kind !== "memo.update") {
throw new Error(`Unsupported sync item kind: ${item.kind}`);
}
const data = await api.updateMemo(item.memoId, {
expectedRevision: item.payload.expectedRevision,
title: item.payload.title,
contentJson: item.payload.contentJson as TiptapDoc,
tags: item.payload.tags,
});
return data.memo;
};
const summarizeSyncQueue = (items: SyncQueueItem[]): SyncQueueSummary =>
items.reduce(
(summary, item) => {
summary.total += 1;
summary[item.status] += 1;
return summary;
},
emptySyncQueueSummary()
);
const isRevisionConflict = (error: unknown) =>
error instanceof ApiRequestError && error.code === "revision_conflict";
const getErrorMessage = (error: unknown) => {
if (error instanceof Error) {
return error.message;
}
return "Sync failed";
};
const nextRetryAt = (attemptCount: number) => {
const delayMs = Math.min(5 * 60_000, 2 ** Math.min(attemptCount, 6) * 1000);
return new Date(Date.now() + delayMs).toISOString();
};
+1463
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -19,6 +19,7 @@
"db:migrate:remote": "bun scripts/run-wrangler.mjs d1 migrations apply DB --remote",
"auth:hash": "bun scripts/hash-password.mjs",
"cli": "bun scripts/edgeever.mjs",
"mcp:stdio": "bun scripts/edgeever-mcp-stdio.mjs",
"deploy": "bun run build && bun run db:migrate:remote && bun scripts/run-wrangler.mjs deploy",
"deploy:tianma": "EDGE_EVER_INSTANCE=tianma bun run deploy",
"deploy:demo": "EDGE_EVER_INSTANCE=demo bun run deploy",
+2
View File
@@ -14,6 +14,8 @@ export type TiptapDoc = {
content: TiptapNode[];
};
export const DEFAULT_MEMO_TITLE = "无标题笔记";
export const emptyDoc = (): TiptapDoc => ({
type: "doc",
content: [{ type: "paragraph" }],
+248
View File
@@ -0,0 +1,248 @@
#!/usr/bin/env bun
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { homedir } from "node:os";
const CONFIG_PATH = process.env.EDGEEVER_CONFIG || join(homedir(), ".edgeever", "config.json");
const DEFAULT_URL = "http://127.0.0.1:8787";
const options = parseOptions(process.argv.slice(2));
const usage = `EdgeEver MCP stdio adapter
Usage:
EDGEEVER_URL=https://your.edgeever.host EDGEEVER_TOKEN=... bun scripts/edgeever-mcp-stdio.mjs
bun scripts/edgeever-mcp-stdio.mjs --profile prod
The adapter bridges local stdio MCP clients to the remote EdgeEver /mcp endpoint.
`;
if (options.help || options.h) {
process.stdout.write(usage);
process.exit(0);
}
let client;
try {
client = await createClient(options.profile);
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
let inputBuffer = Buffer.alloc(0);
let transportMode = null;
process.stdin.on("data", (chunk) => {
inputBuffer = Buffer.concat([inputBuffer, chunk]);
drainInput();
});
process.stdin.on("error", (error) => {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
});
process.stdin.resume();
function drainInput() {
while (inputBuffer.length > 0) {
const framed = readFramedMessage(inputBuffer);
if (framed) {
transportMode = "framed";
inputBuffer = inputBuffer.subarray(framed.consumedBytes);
void handleMessage(framed.payload);
continue;
}
if (looksLikeFramedMessage(inputBuffer)) {
return;
}
const newlineIndex = inputBuffer.indexOf(0x0a);
if (newlineIndex === -1) {
return;
}
const line = inputBuffer.subarray(0, newlineIndex).toString("utf8").trim();
inputBuffer = inputBuffer.subarray(newlineIndex + 1);
if (!line) {
continue;
}
transportMode = transportMode || "line";
void handleMessage(line);
}
}
async function handleMessage(payload) {
let request;
try {
request = JSON.parse(payload);
} catch {
writeMessage(jsonRpcError(null, -32700, "Parse error"));
return;
}
try {
const response = await forwardToEdgeEver(request);
if (response !== null) {
writeMessage(response);
}
} catch (error) {
writeMessage(
jsonRpcError(getJsonRpcId(request), -32000, error instanceof Error ? error.message : "MCP bridge failed")
);
}
}
async function forwardToEdgeEver(request) {
const response = await fetch(`${client.baseUrl}/mcp`, {
method: "POST",
headers: {
Authorization: `Bearer ${client.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(request),
});
if (response.status === 204) {
return null;
}
const body = await response.json().catch(() => null);
if (!body) {
throw new Error(`${response.status} ${response.statusText}`);
}
return body;
}
async function createClient(profileName) {
const config = await readConfig();
const profile = profileName ? config.profiles?.[profileName] : undefined;
const baseUrl = (process.env.EDGEEVER_URL || profile?.url || DEFAULT_URL).replace(/\/+$/, "");
const token = process.env.EDGEEVER_TOKEN || profile?.token;
if (!token) {
throw new Error("EDGEEVER_TOKEN is required, or configure a profile with `bun run cli -- profile set`.");
}
return { baseUrl, token };
}
async function readConfig() {
try {
const value = JSON.parse(await readFile(CONFIG_PATH, "utf8"));
return value && typeof value === "object" ? value : {};
} catch {
return {};
}
}
function readFramedMessage(buffer) {
const headerEnd = findHeaderEnd(buffer);
if (!headerEnd) {
return null;
}
const header = buffer.subarray(0, headerEnd.index).toString("utf8");
const match = /^content-length:\s*(\d+)\s*$/im.exec(header);
if (!match) {
return null;
}
const contentLength = Number(match[1]);
const bodyStart = headerEnd.index + headerEnd.length;
const bodyEnd = bodyStart + contentLength;
if (!Number.isInteger(contentLength) || contentLength < 0 || buffer.length < bodyEnd) {
return null;
}
return {
payload: buffer.subarray(bodyStart, bodyEnd).toString("utf8"),
consumedBytes: bodyEnd,
};
}
function findHeaderEnd(buffer) {
const crlf = buffer.indexOf("\r\n\r\n");
if (crlf !== -1) {
return { index: crlf, length: 4 };
}
const lf = buffer.indexOf("\n\n");
return lf === -1 ? null : { index: lf, length: 2 };
}
function looksLikeFramedMessage(buffer) {
return buffer.subarray(0, Math.min(buffer.length, 32)).toString("utf8").toLowerCase().startsWith("content-length:");
}
function writeMessage(message) {
const payload = `${JSON.stringify(message)}\n`;
if (transportMode === "framed") {
const content = payload.trimEnd();
process.stdout.write(`Content-Length: ${Buffer.byteLength(content, "utf8")}\r\n\r\n${content}`);
return;
}
process.stdout.write(payload);
}
function parseOptions(args) {
const parsed = {};
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (!arg.startsWith("--")) {
continue;
}
const key = arg.slice(2);
const next = args[index + 1];
if (!next || next.startsWith("--")) {
parsed[key] = "true";
continue;
}
parsed[key] = next;
index += 1;
}
return parsed;
}
function getJsonRpcId(request) {
if (!request || typeof request !== "object" || !("id" in request)) {
return null;
}
const id = request.id;
return typeof id === "string" || typeof id === "number" || id === null ? id : null;
}
function jsonRpcError(id, code, message) {
return {
jsonrpc: "2.0",
id,
error: {
code,
message,
},
};
}