wiki-viewer 1.0.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/standalone/README.md +328 -99
- package/.next/standalone/agents/bootstrap-prompt.md +1 -0
- package/.next/standalone/agents/wiki-viewer-skill/SKILL.md +236 -0
- package/.next/standalone/bin/wiki-viewer.js +57 -4
- package/.next/standalone/docs/agent-collab-plan.md +1615 -0
- package/.next/standalone/docs/agent-collab-v2-plan.md +771 -0
- package/.next/standalone/package.json +19 -2
- package/.next/standalone/pnpm-lock.yaml +1368 -325
- package/.next/standalone/pnpm-workspace.yaml +7 -1
- package/.next/standalone/src/app/api/agent/activity/route.ts +58 -0
- package/.next/standalone/src/app/api/agent/admin/agents/[agentId]/revoke/route.ts +40 -0
- package/.next/standalone/src/app/api/agent/admin/agents/route.ts +33 -0
- package/.next/standalone/src/app/api/agent/admin/registrations/[regId]/approve/route.ts +83 -0
- package/.next/standalone/src/app/api/agent/admin/registrations/[regId]/deny/route.ts +36 -0
- package/.next/standalone/src/app/api/agent/admin/registrations/route.ts +27 -0
- package/.next/standalone/src/app/api/agent/events/[...path]/route.ts +136 -0
- package/.next/standalone/src/app/api/agent/files/[...path]/route.ts +202 -0
- package/.next/standalone/src/app/api/agent/internal/span/route.ts +117 -0
- package/.next/standalone/src/app/api/agent/register/[regId]/route.ts +50 -0
- package/.next/standalone/src/app/api/agent/register/route.ts +110 -0
- package/.next/standalone/src/app/api/agent/settings/route.ts +30 -0
- package/.next/standalone/src/app/api/agent/settings/token/regenerate/route.ts +20 -0
- package/.next/standalone/src/app/api/agent/sidecar/[...path]/route.ts +49 -0
- package/.next/standalone/src/app/api/agents/install/route.ts +83 -0
- package/.next/standalone/src/app/api/agents/skill/route.ts +20 -0
- package/.next/standalone/src/app/api/agents/skill.tar.gz/route.ts +87 -0
- package/.next/standalone/src/app/api/auth/[...all]/route.ts +7 -0
- package/.next/standalone/src/app/api/owner/init/route.ts +14 -0
- package/.next/standalone/src/app/api/system/auth-settings/route.ts +85 -0
- package/.next/standalone/src/app/api/system/browse/route.ts +4 -0
- package/.next/standalone/src/app/api/system/clear-root/route.ts +8 -1
- package/.next/standalone/src/app/api/system/config/route.ts +5 -1
- package/.next/standalone/src/app/api/system/pins/route.ts +7 -0
- package/.next/standalone/src/app/api/system/reveal/route.ts +7 -0
- package/.next/standalone/src/app/api/system/root-status/route.ts +5 -1
- package/.next/standalone/src/app/api/system/set-root/route.ts +7 -0
- package/.next/standalone/src/app/api/wiki/app/route.ts +15 -0
- package/.next/standalone/src/app/api/wiki/content/route.ts +110 -11
- package/.next/standalone/src/app/api/wiki/folder/route.ts +7 -0
- package/.next/standalone/src/app/api/wiki/move/route.ts +7 -0
- package/.next/standalone/src/app/api/wiki/new-file/route.ts +55 -0
- package/.next/standalone/src/app/api/wiki/page/route.ts +7 -0
- package/.next/standalone/src/app/api/wiki/route.ts +10 -0
- package/.next/standalone/src/app/api/wiki/slugs/route.ts +5 -1
- package/.next/standalone/src/app/api/wiki/upload/route.ts +7 -0
- package/.next/standalone/src/app/api/wiki/watch/route.ts +6 -1
- package/.next/standalone/src/app/globals.css +70 -0
- package/.next/standalone/src/app/page.tsx +461 -217
- package/.next/standalone/src/app/signin/page.tsx +217 -0
- package/.next/standalone/src/components/ai-panel/activity-row.tsx +64 -0
- package/.next/standalone/src/components/ai-panel/ai-panel.tsx +322 -0
- package/.next/standalone/src/components/ai-panel/token-section.tsx +312 -0
- package/.next/standalone/src/components/auth-settings-sheet.tsx +209 -0
- package/.next/standalone/src/components/editor/bubble-menu.tsx +41 -2
- package/.next/standalone/src/components/editor/comment-pip.tsx +56 -0
- package/.next/standalone/src/components/editor/comment-thread.tsx +252 -0
- package/.next/standalone/src/components/editor/editor.tsx +513 -10
- package/.next/standalone/src/components/editor/extensions/proof-span.ts +60 -0
- package/.next/standalone/src/components/editor/extensions.ts +2 -0
- package/.next/standalone/src/components/editor/proof-span-popover.tsx +161 -0
- package/.next/standalone/src/components/editor/suggest-edit-popover.tsx +228 -0
- package/.next/standalone/src/components/editor/suggestion-card.tsx +201 -0
- package/.next/standalone/src/components/view-width-toggle.tsx +47 -0
- package/.next/standalone/src/components/wiki/markdown-preview.tsx +120 -0
- package/.next/standalone/src/lib/auth/allowlist.ts +50 -0
- package/.next/standalone/src/lib/auth/client.ts +4 -0
- package/.next/standalone/src/lib/auth/csrf.ts +68 -0
- package/.next/standalone/src/lib/auth/server.ts +164 -0
- package/.next/standalone/src/lib/config.ts +4 -0
- package/.next/standalone/src/lib/markdown/parse-frontmatter.ts +20 -0
- package/.next/standalone/src/lib/markdown/sanitize-schema.ts +106 -0
- package/.next/standalone/src/lib/markdown/to-html.ts +46 -8
- package/.next/standalone/src/lib/markdown/to-markdown.ts +14 -0
- package/.next/standalone/src/lib/proof/activity-shared.ts +31 -0
- package/.next/standalone/src/lib/proof/activity.ts +74 -0
- package/.next/standalone/src/lib/proof/auth.ts +132 -0
- package/.next/standalone/src/lib/proof/block-refs.ts +133 -0
- package/.next/standalone/src/lib/proof/blocks.ts +73 -0
- package/.next/standalone/src/lib/proof/client-auth.ts +23 -0
- package/.next/standalone/src/lib/proof/event-bus.ts +47 -0
- package/.next/standalone/src/lib/proof/file-lock.ts +38 -0
- package/.next/standalone/src/lib/proof/glob.ts +51 -0
- package/.next/standalone/src/lib/proof/idempotency.ts +32 -0
- package/.next/standalone/src/lib/proof/mutex.ts +32 -0
- package/.next/standalone/src/lib/proof/ops-applier.ts +678 -0
- package/.next/standalone/src/lib/proof/owner-auth.ts +2 -0
- package/.next/standalone/src/lib/proof/pending.ts +97 -0
- package/.next/standalone/src/lib/proof/proof-span.ts +141 -0
- package/.next/standalone/src/lib/proof/rate-limit.ts +53 -0
- package/.next/standalone/src/lib/proof/register-rate-limit.ts +53 -0
- package/.next/standalone/src/lib/proof/registry.ts +190 -0
- package/.next/standalone/src/lib/proof/sidecar.ts +66 -0
- package/.next/standalone/src/lib/proof/suggest-capture.ts +69 -0
- package/.next/standalone/src/lib/proof/types.ts +170 -0
- package/.next/standalone/src/lib/proof-config.ts +14 -0
- package/.next/standalone/src/middleware.ts +38 -0
- package/.next/standalone/src/stores/ai-panel-store.ts +58 -3
- package/.next/standalone/src/stores/editor-store.ts +115 -9
- package/.next/standalone/src/stores/proof-store.ts +123 -0
- package/.next/standalone/src/stores/view-width-store.ts +62 -0
- package/.next/standalone/src/tests/proof/activity-aggregator.test.ts +146 -0
- package/.next/standalone/src/tests/proof/agent-ownership.test.ts +140 -0
- package/.next/standalone/src/tests/proof/agents-install.test.ts +57 -0
- package/.next/standalone/src/tests/proof/better-auth.test.ts +68 -0
- package/.next/standalone/src/tests/proof/block-refs.test.ts +108 -0
- package/.next/standalone/src/tests/proof/blocks.test.ts +92 -0
- package/.next/standalone/src/tests/proof/comments-ops.test.ts +177 -0
- package/.next/standalone/src/tests/proof/editor-roundtrip.test.ts +85 -0
- package/.next/standalone/src/tests/proof/external-edit.test.ts +58 -0
- package/.next/standalone/src/tests/proof/file-lock.test.ts +80 -0
- package/.next/standalone/src/tests/proof/glob.test.ts +82 -0
- package/.next/standalone/src/tests/proof/helpers/auth-session.ts +27 -0
- package/.next/standalone/src/tests/proof/markdown-to-html.test.ts +46 -0
- package/.next/standalone/src/tests/proof/ops-applier.test.ts +368 -0
- package/.next/standalone/src/tests/proof/owner-init.test.ts +2 -0
- package/.next/standalone/src/tests/proof/parse-frontmatter.test.ts +60 -0
- package/.next/standalone/src/tests/proof/proof-span.test.ts +98 -0
- package/.next/standalone/src/tests/proof/rate-limit.test.ts +54 -0
- package/.next/standalone/src/tests/proof/registration-flow.test.ts +330 -0
- package/.next/standalone/src/tests/proof/registry.test.ts +169 -0
- package/.next/standalone/src/tests/proof/routes.test.ts +516 -0
- package/.next/standalone/src/tests/proof/sidecar-trim.test.ts +58 -0
- package/.next/standalone/src/tests/proof/suggestion-ops.test.ts +280 -0
- package/.next/standalone/src/tests/proof/wiki-content-put.test.ts +140 -0
- package/.next/standalone/src/tests/proof/wiki-routes-auth.test.ts +208 -0
- package/README.md +328 -99
- package/bin/wiki-viewer.js +57 -4
- package/package.json +19 -2
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
export type BlockType =
|
|
2
|
+
| "heading"
|
|
3
|
+
| "paragraph"
|
|
4
|
+
| "bulletList"
|
|
5
|
+
| "orderedList"
|
|
6
|
+
| "taskList"
|
|
7
|
+
| "blockquote"
|
|
8
|
+
| "codeBlock"
|
|
9
|
+
| "table"
|
|
10
|
+
| "hr"
|
|
11
|
+
| "html";
|
|
12
|
+
|
|
13
|
+
export interface Block {
|
|
14
|
+
ref: string; // "b" + 6-hex
|
|
15
|
+
type: BlockType;
|
|
16
|
+
level?: number; // headings only
|
|
17
|
+
lang?: string; // codeBlock only
|
|
18
|
+
markdown: string; // canonical markdown for this block, trailing \n stripped
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ProvenanceMeta {
|
|
22
|
+
origin: "human" | "ai";
|
|
23
|
+
basis?: "described" | "inferred" | "suggested";
|
|
24
|
+
basisDetail?: string;
|
|
25
|
+
by?: string; // "ai:claude" or "human"
|
|
26
|
+
at?: string; // ISO 8601
|
|
27
|
+
spanId: string; // "p" + 4-hex
|
|
28
|
+
inResponseTo?: string; // comment id
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface CommentTurn {
|
|
32
|
+
by: string; // "human" | "ai:claude"
|
|
33
|
+
text: string;
|
|
34
|
+
at: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface Comment {
|
|
38
|
+
id: string; // "c" + 4-hex
|
|
39
|
+
ref: string; // block ref it's attached to
|
|
40
|
+
resolved: boolean;
|
|
41
|
+
createdAt: string;
|
|
42
|
+
turns: CommentTurn[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type SuggestionKind =
|
|
46
|
+
| "replace"
|
|
47
|
+
| "insertAfter"
|
|
48
|
+
| "insertBefore"
|
|
49
|
+
| "delete";
|
|
50
|
+
export type SuggestionStatus = "pending" | "accepted" | "rejected";
|
|
51
|
+
|
|
52
|
+
export interface Suggestion {
|
|
53
|
+
id: string; // "s" + 4-hex
|
|
54
|
+
ref: string;
|
|
55
|
+
kind: SuggestionKind;
|
|
56
|
+
status: SuggestionStatus;
|
|
57
|
+
by: string;
|
|
58
|
+
markdown?: string; // omitted for kind=delete
|
|
59
|
+
basis?: ProvenanceMeta["basis"];
|
|
60
|
+
basisDetail?: string;
|
|
61
|
+
createdAt: string;
|
|
62
|
+
resolvedAt?: string; // when accepted/rejected
|
|
63
|
+
resolvedBy?: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ProofEvent {
|
|
67
|
+
id: number;
|
|
68
|
+
type: string; // see §4.6 list
|
|
69
|
+
at: string;
|
|
70
|
+
by: string;
|
|
71
|
+
[k: string]: unknown;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface SpanAttrs {
|
|
75
|
+
spanId: string;
|
|
76
|
+
origin: "ai" | "human";
|
|
77
|
+
basis?: string;
|
|
78
|
+
basisDetail?: string;
|
|
79
|
+
by: string;
|
|
80
|
+
at: string;
|
|
81
|
+
inResponseTo?: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface Sidecar {
|
|
85
|
+
schemaVersion: 1;
|
|
86
|
+
path: string;
|
|
87
|
+
revision: number;
|
|
88
|
+
createdAt: string;
|
|
89
|
+
updatedAt: string;
|
|
90
|
+
// Map of block.ref -> current text fingerprint (sha256 of block markdown, first 12 hex).
|
|
91
|
+
refMap: Record<string, { textHash: string; lastSeenAt: string }>;
|
|
92
|
+
// History of ref renames. Old ref -> new ref, kept for ONE generation.
|
|
93
|
+
refAliases: Record<string, string>;
|
|
94
|
+
comments: Comment[];
|
|
95
|
+
suggestions: Suggestion[];
|
|
96
|
+
archivedSuggestions: Suggestion[];
|
|
97
|
+
events: ProofEvent[];
|
|
98
|
+
nextEventId: number;
|
|
99
|
+
lastAck: Record<string, number>; // by -> eventId
|
|
100
|
+
fingerprint: string; // last-known sha256 of the .md file
|
|
101
|
+
blockProvenance?: Record<string, SpanAttrs>; // for blocks we can't wrap inline
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type Op =
|
|
105
|
+
| {
|
|
106
|
+
type: "block.replace";
|
|
107
|
+
ref: string;
|
|
108
|
+
markdown: string;
|
|
109
|
+
basis?: string;
|
|
110
|
+
basisDetail?: string;
|
|
111
|
+
inResponseTo?: string;
|
|
112
|
+
}
|
|
113
|
+
| {
|
|
114
|
+
type: "block.insertAfter";
|
|
115
|
+
ref: string;
|
|
116
|
+
markdown: string;
|
|
117
|
+
basis?: string;
|
|
118
|
+
basisDetail?: string;
|
|
119
|
+
inResponseTo?: string;
|
|
120
|
+
}
|
|
121
|
+
| {
|
|
122
|
+
type: "block.insertBefore";
|
|
123
|
+
ref: string;
|
|
124
|
+
markdown: string;
|
|
125
|
+
basis?: string;
|
|
126
|
+
basisDetail?: string;
|
|
127
|
+
inResponseTo?: string;
|
|
128
|
+
}
|
|
129
|
+
| { type: "block.delete"; ref: string }
|
|
130
|
+
| {
|
|
131
|
+
type: "block.append";
|
|
132
|
+
markdown: string;
|
|
133
|
+
basis?: string;
|
|
134
|
+
basisDetail?: string;
|
|
135
|
+
inResponseTo?: string;
|
|
136
|
+
}
|
|
137
|
+
| {
|
|
138
|
+
type: "block.prepend";
|
|
139
|
+
markdown: string;
|
|
140
|
+
basis?: string;
|
|
141
|
+
basisDetail?: string;
|
|
142
|
+
inResponseTo?: string;
|
|
143
|
+
}
|
|
144
|
+
| { type: "comment.add"; ref: string; text: string }
|
|
145
|
+
| { type: "comment.reply"; commentId: string; text: string }
|
|
146
|
+
| { type: "comment.resolve"; commentId: string }
|
|
147
|
+
| { type: "comment.reopen"; commentId: string }
|
|
148
|
+
| {
|
|
149
|
+
type: "suggestion.add";
|
|
150
|
+
ref: string;
|
|
151
|
+
kind: SuggestionKind;
|
|
152
|
+
markdown?: string;
|
|
153
|
+
basis?: string;
|
|
154
|
+
basisDetail?: string;
|
|
155
|
+
status?: SuggestionStatus;
|
|
156
|
+
}
|
|
157
|
+
| { type: "suggestion.accept"; suggestionId: string }
|
|
158
|
+
| { type: "suggestion.reject"; suggestionId: string };
|
|
159
|
+
|
|
160
|
+
export interface Snapshot {
|
|
161
|
+
path: string;
|
|
162
|
+
revision: number;
|
|
163
|
+
createdAt: string;
|
|
164
|
+
updatedAt: string;
|
|
165
|
+
fingerprint: string;
|
|
166
|
+
blocks: Block[];
|
|
167
|
+
comments: Comment[]; // unresolved + resolved (separately by client)
|
|
168
|
+
suggestions: Suggestion[]; // pending only by default
|
|
169
|
+
lastEventId: number;
|
|
170
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Rate limiting: token bucket per "by" agent identity.
|
|
2
|
+
// Bucket size = OPS_PER_MINUTE, refill 1 op/sec.
|
|
3
|
+
export const OPS_PER_MINUTE = (() => {
|
|
4
|
+
const v = parseInt(process.env.AGENT_RATE_LIMIT ?? "", 10);
|
|
5
|
+
return Number.isFinite(v) && v > 0 ? v : 60;
|
|
6
|
+
})();
|
|
7
|
+
|
|
8
|
+
// Max events to return from a single poll.
|
|
9
|
+
export const EVENTS_MAX_LIMIT = 1000;
|
|
10
|
+
export const EVENTS_DEFAULT_LIMIT = 100;
|
|
11
|
+
|
|
12
|
+
// Sidecar event trimming: keep last N events (but never below oldest ack cursor).
|
|
13
|
+
export const SIDECAR_EVENT_TRIM_SIZE = 1000;
|
|
14
|
+
export const SIDECAR_TRIM_EVERY_N_MUTATIONS = 100;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { NextResponse, type NextRequest } from "next/server";
|
|
2
|
+
|
|
3
|
+
// Prefixes the middleware never intercepts. Either they're public, or the route
|
|
4
|
+
// handler itself runs Better Auth / agent bearer auth and returns a proper
|
|
5
|
+
// status (401/403/200) instead of a redirect to /signin.
|
|
6
|
+
const PASSTHROUGH_PREFIXES = [
|
|
7
|
+
"/signin",
|
|
8
|
+
"/api/", // ALL API routes self-gate; middleware never redirects API
|
|
9
|
+
"/_next",
|
|
10
|
+
"/icon.svg",
|
|
11
|
+
"/favicon.ico",
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
export function middleware(req: NextRequest): NextResponse {
|
|
15
|
+
const { pathname } = req.nextUrl;
|
|
16
|
+
|
|
17
|
+
if (PASSTHROUGH_PREFIXES.some((p) => pathname.startsWith(p))) {
|
|
18
|
+
return NextResponse.next();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Cheap presence check; real session validation happens in individual routes.
|
|
22
|
+
// Cookie name from better-auth default: "better-auth.session_token"
|
|
23
|
+
const sessionCookie =
|
|
24
|
+
req.cookies.get("better-auth.session_token") ??
|
|
25
|
+
req.cookies.get("__Secure-better-auth.session_token");
|
|
26
|
+
|
|
27
|
+
if (!sessionCookie) {
|
|
28
|
+
const url = new URL("/signin", req.url);
|
|
29
|
+
url.searchParams.set("next", pathname);
|
|
30
|
+
return NextResponse.redirect(url);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return NextResponse.next();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const config = {
|
|
37
|
+
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
|
38
|
+
};
|
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { create } from "zustand";
|
|
3
|
+
import type { ActivityEvent } from "@/lib/proof/activity-shared";
|
|
4
|
+
import { deriveConnections } from "@/lib/proof/activity-shared";
|
|
5
|
+
import { authHeaders } from "@/lib/proof/client-auth";
|
|
3
6
|
|
|
4
|
-
|
|
5
|
-
|
|
7
|
+
export interface Connection {
|
|
8
|
+
by: string;
|
|
9
|
+
opCount: number;
|
|
10
|
+
lastSeen: string;
|
|
11
|
+
}
|
|
6
12
|
|
|
7
13
|
interface AIPanelState {
|
|
8
14
|
isOpen: boolean;
|
|
@@ -10,6 +16,14 @@ interface AIPanelState {
|
|
|
10
16
|
close: () => void;
|
|
11
17
|
toggle: () => void;
|
|
12
18
|
clearMessages: () => void;
|
|
19
|
+
|
|
20
|
+
activity: ActivityEvent[];
|
|
21
|
+
connections: Connection[];
|
|
22
|
+
hasToken: boolean;
|
|
23
|
+
pollIntervalMs: number;
|
|
24
|
+
rateLimit: number | null;
|
|
25
|
+
|
|
26
|
+
loadActivity: () => Promise<void>;
|
|
13
27
|
}
|
|
14
28
|
|
|
15
29
|
export const useAIPanelStore = create<AIPanelState>((set) => ({
|
|
@@ -18,6 +32,47 @@ export const useAIPanelStore = create<AIPanelState>((set) => ({
|
|
|
18
32
|
close: () => set({ isOpen: false }),
|
|
19
33
|
toggle: () => set((s) => ({ isOpen: !s.isOpen })),
|
|
20
34
|
clearMessages: () => {
|
|
21
|
-
/* no-op
|
|
35
|
+
/* no-op */
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
activity: [],
|
|
39
|
+
connections: [],
|
|
40
|
+
hasToken: false,
|
|
41
|
+
pollIntervalMs: 10_000,
|
|
42
|
+
rateLimit: null,
|
|
43
|
+
|
|
44
|
+
loadActivity: async () => {
|
|
45
|
+
const token =
|
|
46
|
+
typeof window !== "undefined"
|
|
47
|
+
? (localStorage.getItem("wiki-agent-token") ?? null)
|
|
48
|
+
: null;
|
|
49
|
+
const hasToken = !!(token && token.trim());
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const res = await fetch("/api/agent/activity?limit=50", {
|
|
53
|
+
headers: authHeaders(),
|
|
54
|
+
});
|
|
55
|
+
if (!res.ok) {
|
|
56
|
+
set({ hasToken });
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const data = (await res.json()) as { events: ActivityEvent[] };
|
|
60
|
+
const activity = data.events ?? [];
|
|
61
|
+
const connections = deriveConnections(activity);
|
|
62
|
+
|
|
63
|
+
// Also fetch rate-limit setting
|
|
64
|
+
let rateLimit: number | null = null;
|
|
65
|
+
try {
|
|
66
|
+
const sr = await fetch("/api/agent/settings", { headers: authHeaders() });
|
|
67
|
+
if (sr.ok) {
|
|
68
|
+
const sd = (await sr.json()) as { rateLimit: number };
|
|
69
|
+
rateLimit = sd.rateLimit ?? null;
|
|
70
|
+
}
|
|
71
|
+
} catch { /* ignore */ }
|
|
72
|
+
|
|
73
|
+
set({ activity, connections, hasToken, rateLimit });
|
|
74
|
+
} catch {
|
|
75
|
+
set({ hasToken });
|
|
76
|
+
}
|
|
22
77
|
},
|
|
23
78
|
}));
|
|
@@ -19,6 +19,7 @@ interface PageData {
|
|
|
19
19
|
path: string;
|
|
20
20
|
content: string;
|
|
21
21
|
frontmatter: FrontMatter | null;
|
|
22
|
+
revision: number | null;
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
async function fetchPageFromApi(path: string): Promise<PageData> {
|
|
@@ -27,23 +28,57 @@ async function fetchPageFromApi(path: string): Promise<PageData> {
|
|
|
27
28
|
throw new FetchPageError(`Failed to fetch page: ${path}`, res.status);
|
|
28
29
|
}
|
|
29
30
|
const data: { content: string } = await res.json();
|
|
30
|
-
|
|
31
|
+
const revHeader = res.headers.get("X-Wiki-Revision");
|
|
32
|
+
const revision = revHeader !== null ? Number(revHeader) : null;
|
|
33
|
+
return { path, content: data.content, frontmatter: null, revision };
|
|
31
34
|
}
|
|
32
35
|
|
|
33
|
-
|
|
36
|
+
interface SaveResult {
|
|
37
|
+
revision: number | null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Error thrown when the server reports a stale revision (409). */
|
|
41
|
+
export class StaleRevisionError extends Error {
|
|
42
|
+
constructor(
|
|
43
|
+
public readonly currentRevision: number,
|
|
44
|
+
public readonly serverContent: string,
|
|
45
|
+
) {
|
|
46
|
+
super("File was modified externally. Reloaded with latest content.");
|
|
47
|
+
this.name = "StaleRevisionError";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function savePageToApi(
|
|
52
|
+
path: string,
|
|
53
|
+
content: string,
|
|
54
|
+
baseRevision: number | null,
|
|
55
|
+
): Promise<SaveResult> {
|
|
56
|
+
const body: Record<string, unknown> = { path, content };
|
|
57
|
+
if (baseRevision !== null) body.baseRevision = baseRevision;
|
|
34
58
|
const res = await fetch("/api/wiki/content", {
|
|
35
59
|
method: "PUT",
|
|
36
60
|
headers: { "Content-Type": "application/json" },
|
|
37
|
-
body: JSON.stringify(
|
|
61
|
+
body: JSON.stringify(body),
|
|
38
62
|
});
|
|
63
|
+
if (res.status === 409) {
|
|
64
|
+
// Server has a newer revision. Fetch current content and surface the conflict.
|
|
65
|
+
const conflictData = (await res.json()) as { currentRevision?: number };
|
|
66
|
+
const freshRes = await fetch(`/api/wiki/content?path=${encodeURIComponent(path)}`);
|
|
67
|
+
const freshContent = freshRes.ok
|
|
68
|
+
? ((await freshRes.json()) as { content: string }).content
|
|
69
|
+
: content;
|
|
70
|
+
throw new StaleRevisionError(conflictData.currentRevision ?? 0, freshContent);
|
|
71
|
+
}
|
|
39
72
|
if (!res.ok) {
|
|
40
73
|
throw new Error("Failed to save page");
|
|
41
74
|
}
|
|
75
|
+
const data = (await res.json()) as { revision?: number };
|
|
76
|
+
return { revision: data.revision ?? null };
|
|
42
77
|
}
|
|
43
78
|
|
|
44
79
|
async function createPageInApi(path: string, content = ""): Promise<void> {
|
|
45
80
|
// ccmc has no dedicated create endpoint; saving empty content creates the file.
|
|
46
|
-
await savePageToApi(path, content);
|
|
81
|
+
await savePageToApi(path, content, null);
|
|
47
82
|
}
|
|
48
83
|
|
|
49
84
|
const PAGE_CACHE_KEY = "kb-page-cache";
|
|
@@ -78,6 +113,15 @@ function saveCachedPage(page: CachedPage) {
|
|
|
78
113
|
|
|
79
114
|
export type LoadStatus = "idle" | "loading" | "ok" | "missing" | "error";
|
|
80
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Editor interaction mode.
|
|
118
|
+
* - "editing": human edits write directly to the file (full-file PUT).
|
|
119
|
+
* - "suggesting": human block edits are captured as suggestions (block ops,
|
|
120
|
+
* by: "human") and the editor block reverts to the snapshot. The file is
|
|
121
|
+
* not changed until a suggestion is accepted.
|
|
122
|
+
*/
|
|
123
|
+
export type EditMode = "editing" | "suggesting";
|
|
124
|
+
|
|
81
125
|
interface EditorState {
|
|
82
126
|
currentPath: string | null;
|
|
83
127
|
content: string;
|
|
@@ -87,6 +131,10 @@ interface EditorState {
|
|
|
87
131
|
isDirty: boolean;
|
|
88
132
|
isLoading: boolean;
|
|
89
133
|
lastSavedAt: number | null;
|
|
134
|
+
/** Last confirmed revision from the server. null until first save/sync. */
|
|
135
|
+
currentRevision: number | null;
|
|
136
|
+
/** Current interaction mode. Persisted in localStorage. */
|
|
137
|
+
editMode: EditMode;
|
|
90
138
|
|
|
91
139
|
loadPage: (path: string) => Promise<void>;
|
|
92
140
|
updateContent: (content: string) => void;
|
|
@@ -94,6 +142,19 @@ interface EditorState {
|
|
|
94
142
|
save: () => Promise<void>;
|
|
95
143
|
createMissingPage: (title: string) => Promise<void>;
|
|
96
144
|
clear: () => void;
|
|
145
|
+
/** Sync the known revision from an external source (e.g. proof-store snapshot). */
|
|
146
|
+
syncRevision: (revision: number) => void;
|
|
147
|
+
/** Switch interaction mode (editing | suggesting). */
|
|
148
|
+
setEditMode: (mode: EditMode) => void;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const EDIT_MODE_KEY = "kb-edit-mode";
|
|
152
|
+
|
|
153
|
+
function loadEditMode(): EditMode {
|
|
154
|
+
if (typeof window === "undefined") return "editing";
|
|
155
|
+
return localStorage.getItem(EDIT_MODE_KEY) === "suggesting"
|
|
156
|
+
? "suggesting"
|
|
157
|
+
: "editing";
|
|
97
158
|
}
|
|
98
159
|
|
|
99
160
|
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -108,6 +169,15 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|
|
108
169
|
isDirty: false,
|
|
109
170
|
isLoading: false,
|
|
110
171
|
lastSavedAt: null,
|
|
172
|
+
currentRevision: null,
|
|
173
|
+
editMode: loadEditMode(),
|
|
174
|
+
|
|
175
|
+
setEditMode: (mode: EditMode) => {
|
|
176
|
+
if (typeof window !== "undefined") {
|
|
177
|
+
localStorage.setItem(EDIT_MODE_KEY, mode);
|
|
178
|
+
}
|
|
179
|
+
set({ editMode: mode });
|
|
180
|
+
},
|
|
111
181
|
|
|
112
182
|
loadPage: async (path: string) => {
|
|
113
183
|
const currentState = get();
|
|
@@ -126,6 +196,7 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|
|
126
196
|
loadStatus: "loading",
|
|
127
197
|
isDirty: false,
|
|
128
198
|
content: "",
|
|
199
|
+
currentRevision: null,
|
|
129
200
|
});
|
|
130
201
|
|
|
131
202
|
// Paint from cache immediately so the editor feels instant.
|
|
@@ -148,6 +219,7 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|
|
148
219
|
frontmatter: page.frontmatter,
|
|
149
220
|
isLoading: false,
|
|
150
221
|
loadStatus: "ok",
|
|
222
|
+
currentRevision: page.revision,
|
|
151
223
|
});
|
|
152
224
|
saveCachedPage({
|
|
153
225
|
path,
|
|
@@ -166,6 +238,13 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|
|
166
238
|
},
|
|
167
239
|
|
|
168
240
|
updateContent: (content: string) => {
|
|
241
|
+
// In suggesting mode, human edits never write the file directly. They are
|
|
242
|
+
// captured as suggestions (block ops) and the editor reverts. Skip the
|
|
243
|
+
// dirty flag + autosave entirely so no full-file PUT is scheduled.
|
|
244
|
+
if (get().editMode === "suggesting") {
|
|
245
|
+
set({ content });
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
169
248
|
set({ content, isDirty: true });
|
|
170
249
|
|
|
171
250
|
// Auto-save after 500 ms of inactivity.
|
|
@@ -188,13 +267,20 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|
|
188
267
|
},
|
|
189
268
|
|
|
190
269
|
save: async () => {
|
|
191
|
-
const { currentPath, content, isDirty } = get();
|
|
270
|
+
const { currentPath, content, isDirty, currentRevision, editMode } = get();
|
|
192
271
|
if (!currentPath || !isDirty) return;
|
|
272
|
+
// Suggesting mode must never write the file via full-file PUT.
|
|
273
|
+
if (editMode === "suggesting") return;
|
|
193
274
|
|
|
194
275
|
set({ saveStatus: "saving" });
|
|
195
276
|
try {
|
|
196
|
-
await savePageToApi(currentPath, content);
|
|
197
|
-
set({
|
|
277
|
+
const result = await savePageToApi(currentPath, content, currentRevision);
|
|
278
|
+
set({
|
|
279
|
+
saveStatus: "saved",
|
|
280
|
+
isDirty: false,
|
|
281
|
+
lastSavedAt: Date.now(),
|
|
282
|
+
currentRevision: result.revision ?? currentRevision,
|
|
283
|
+
});
|
|
198
284
|
saveCachedPage({
|
|
199
285
|
path: currentPath,
|
|
200
286
|
content,
|
|
@@ -205,8 +291,23 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|
|
205
291
|
statusTimer = setTimeout(() => {
|
|
206
292
|
if (get().saveStatus === "saved") set({ saveStatus: "idle" });
|
|
207
293
|
}, 2000);
|
|
208
|
-
} catch {
|
|
209
|
-
|
|
294
|
+
} catch (err) {
|
|
295
|
+
if (err instanceof StaleRevisionError) {
|
|
296
|
+
// Server has a newer revision: reload editor with fresh content.
|
|
297
|
+
set({
|
|
298
|
+
content: err.serverContent,
|
|
299
|
+
isDirty: false,
|
|
300
|
+
saveStatus: "error",
|
|
301
|
+
currentRevision: err.currentRevision,
|
|
302
|
+
});
|
|
303
|
+
saveCachedPage({
|
|
304
|
+
path: currentPath,
|
|
305
|
+
content: err.serverContent,
|
|
306
|
+
frontmatter: get().frontmatter,
|
|
307
|
+
});
|
|
308
|
+
} else {
|
|
309
|
+
set({ saveStatus: "error" });
|
|
310
|
+
}
|
|
210
311
|
}
|
|
211
312
|
},
|
|
212
313
|
|
|
@@ -239,6 +340,11 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|
|
239
340
|
isDirty: false,
|
|
240
341
|
isLoading: false,
|
|
241
342
|
lastSavedAt: null,
|
|
343
|
+
currentRevision: null,
|
|
242
344
|
});
|
|
243
345
|
},
|
|
346
|
+
|
|
347
|
+
syncRevision: (revision: number) => {
|
|
348
|
+
set({ currentRevision: revision });
|
|
349
|
+
},
|
|
244
350
|
}));
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { create } from "zustand";
|
|
2
|
+
import { authHeaders } from "@/lib/proof/client-auth";
|
|
3
|
+
import { useEditorStore } from "@/stores/editor-store";
|
|
4
|
+
import type { Sidecar, ProofEvent, Block, Snapshot } from "@/lib/proof/types";
|
|
5
|
+
|
|
6
|
+
interface PathEntry {
|
|
7
|
+
sidecar: Sidecar | null;
|
|
8
|
+
snapshotRevision: number;
|
|
9
|
+
lastEventId: number;
|
|
10
|
+
/** Ordered block list from latest GET snapshot. Used to resolve ref→position in editor. */
|
|
11
|
+
snapshotBlocks: Block[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ProofState {
|
|
15
|
+
byPath: Record<string, PathEntry>;
|
|
16
|
+
loadSidecar(path: string): Promise<void>;
|
|
17
|
+
/** Fetch GET /api/agent/files/<path> to get ordered block list for ref positioning. */
|
|
18
|
+
loadSnapshot(path: string): Promise<void>;
|
|
19
|
+
pollEvents(path: string): Promise<void>;
|
|
20
|
+
applyEvent(path: string, e: ProofEvent): void;
|
|
21
|
+
reset(path: string): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function defaultEntry(): PathEntry {
|
|
25
|
+
return { sidecar: null, snapshotRevision: 0, lastEventId: 0, snapshotBlocks: [] };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const useProofStore = create<ProofState>((set, get) => ({
|
|
29
|
+
byPath: {},
|
|
30
|
+
|
|
31
|
+
loadSidecar: async (path: string) => {
|
|
32
|
+
const encoded = encodeURIComponent(path).replace(/%2F/g, "/");
|
|
33
|
+
try {
|
|
34
|
+
const res = await fetch(`/api/agent/sidecar/${encoded}`, {
|
|
35
|
+
headers: authHeaders(),
|
|
36
|
+
});
|
|
37
|
+
if (!res.ok) return;
|
|
38
|
+
const sidecar = (await res.json()) as Sidecar;
|
|
39
|
+
set((s) => ({
|
|
40
|
+
byPath: {
|
|
41
|
+
...s.byPath,
|
|
42
|
+
[path]: {
|
|
43
|
+
...(s.byPath[path] ?? defaultEntry()),
|
|
44
|
+
sidecar,
|
|
45
|
+
snapshotRevision: sidecar.revision,
|
|
46
|
+
lastEventId: sidecar.nextEventId - 1,
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
}));
|
|
50
|
+
} catch {
|
|
51
|
+
// network error — leave stale
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
loadSnapshot: async (path: string) => {
|
|
56
|
+
const encoded = encodeURIComponent(path).replace(/%2F/g, "/");
|
|
57
|
+
try {
|
|
58
|
+
const res = await fetch(`/api/agent/files/${encoded}`, {
|
|
59
|
+
headers: authHeaders(),
|
|
60
|
+
});
|
|
61
|
+
if (!res.ok) return;
|
|
62
|
+
const snap = (await res.json()) as Snapshot;
|
|
63
|
+
set((s) => ({
|
|
64
|
+
byPath: {
|
|
65
|
+
...s.byPath,
|
|
66
|
+
[path]: {
|
|
67
|
+
...(s.byPath[path] ?? defaultEntry()),
|
|
68
|
+
snapshotBlocks: snap.blocks,
|
|
69
|
+
snapshotRevision: snap.revision,
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
}));
|
|
73
|
+
// Sync revision into editor-store so saves send correct baseRevision.
|
|
74
|
+
const editorState = useEditorStore.getState();
|
|
75
|
+
if (editorState.currentPath === path) {
|
|
76
|
+
editorState.syncRevision(snap.revision);
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
// network error — leave stale
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
pollEvents: async (path: string) => {
|
|
84
|
+
const entry = get().byPath[path] ?? defaultEntry();
|
|
85
|
+
const encoded = encodeURIComponent(path).replace(/%2F/g, "/");
|
|
86
|
+
try {
|
|
87
|
+
const res = await fetch(
|
|
88
|
+
`/api/agent/events/${encoded}?after=${entry.lastEventId}`,
|
|
89
|
+
{ headers: authHeaders() },
|
|
90
|
+
);
|
|
91
|
+
if (!res.ok) return;
|
|
92
|
+
const data = (await res.json()) as { events: ProofEvent[]; lastEventId: number };
|
|
93
|
+
for (const e of data.events) {
|
|
94
|
+
get().applyEvent(path, e);
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
// network error — leave stale
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
applyEvent: (path: string, e: ProofEvent) => {
|
|
102
|
+
set((s) => {
|
|
103
|
+
const prev = s.byPath[path] ?? defaultEntry();
|
|
104
|
+
return {
|
|
105
|
+
byPath: {
|
|
106
|
+
...s.byPath,
|
|
107
|
+
[path]: {
|
|
108
|
+
...prev,
|
|
109
|
+
lastEventId: Math.max(prev.lastEventId, e.id),
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
});
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
reset: (path: string) => {
|
|
117
|
+
set((s) => {
|
|
118
|
+
const next = { ...s.byPath };
|
|
119
|
+
delete next[path];
|
|
120
|
+
return { byPath: next };
|
|
121
|
+
});
|
|
122
|
+
},
|
|
123
|
+
}));
|