tfrere HF Staff commited on
Commit
d63dcfe
·
1 Parent(s): f469961

chore(release): prep v0.1.0 with LICENSE, env example and agent undo test

Browse files

- Add LICENSE (CC-BY 4.0) aligned with the upstream research-article-template
- Complete backend/.env.example to cover every env var consumed by the
backend (PORT, DATA_DIR, ENABLE_PDF, OAUTH_*, SPACE_ID/SPACE_HOST,
HF_DATASET_ID, HF_TOKEN, OPENROUTER_API_KEY/MODEL, PUBLISH_BASE_URL)
- Extract createAgentBatch() helper from useAgentChat into
frontend/src/editor/agent-undo-batch.ts (pure module, no React) and add
the P0 5.3.1 test from docs/TESTS.md: 3 chained agent edits
(replaceSelection + applyDiff + updateFrontmatter style) revert with a
single Cmd+Z, while the previous user edit stays untouched
- Refresh README known-debt list to reflect the new coverage

Made-with: Cursor

LICENSE ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Creative Commons Attribution 4.0 International License
2
+
3
+ Copyright (c) 2026 Thibaud Frere
4
+
5
+ This work is licensed under the Creative Commons Attribution 4.0 International License.
6
+ To view a copy of this license, visit http://creativecommons.org/licenses/by/4.0/
7
+ or send a letter to Creative Commons, PO Box 1866, Mountain View, CA 94042, USA.
8
+
9
+ You are free to:
10
+
11
+ Share — copy and redistribute the material in any medium or format
12
+ Adapt — remix, transform, and build upon the material for any purpose, even commercially.
13
+
14
+ The licensor cannot revoke these freedoms as long as you follow the license terms.
15
+
16
+ Under the following terms:
17
+
18
+ Attribution — You must give appropriate credit, provide a link to the license, and indicate if changes were made. You may do so in any reasonable manner, but not in any way that suggests the licensor endorses you or your use.
19
+
20
+ No additional restrictions — You may not apply legal terms or technological measures that legally restrict others from doing anything the license permits.
21
+
22
+ Notices:
23
+
24
+ You do not have to comply with the license for elements of the material in the public domain or where your use is permitted by an applicable exception or limitation.
25
+
26
+ No warranties are given. The license may not give you all of the permissions necessary for your intended use. For example, other rights such as publicity, privacy, or moral rights may limit how you use the material.
27
+
28
+ ---
29
+
30
+ For the source code and technical implementation:
31
+ - The source code is available at: https://huggingface.co/spaces/tfrere/research-article-template-editor
32
+ - This project builds on the research-article-template (https://github.com/huggingface/research-article-template) and inherits its visual design and CSS foundation
33
+ - Dependencies and third-party libraries maintain their respective licenses
README.md CHANGED
@@ -169,7 +169,7 @@ See [`docs/TESTS.md`](docs/TESTS.md) for the current strategy and gaps.
169
 
170
  These are tracked explicitly so new contributors don't trip on them:
171
 
172
- - **`useAgentChat` / `useEmbedChat` still lack dedicated unit tests**; the rest of the stores (frontmatter, comments, embeds) are now covered.
173
  - **Bundle size warning**: the frontend bundle is over the 500 kB Vite warning threshold. Code-splitting the Mermaid / KaTeX / D3 stacks via dynamic imports would help.
174
  - **`addToolOutput` typing**: the ai-sdk v6 `ChatAddToolOutputFunction` is a generic over the tool name union. We currently cast to a plain signature at the two call sites because we don't export a typed tool registry yet.
175
  - **`backend/src/publisher/html-renderer.ts` is ~1000 LOC**: a per-node-type registry would make it more maintainable.
 
169
 
170
  These are tracked explicitly so new contributors don't trip on them:
171
 
172
+ - **`useEmbedChat` still lacks dedicated unit tests**; the rest of the stores (frontmatter, comments, embeds) and the agent undo batching primitive are now covered.
173
  - **Bundle size warning**: the frontend bundle is over the 500 kB Vite warning threshold. Code-splitting the Mermaid / KaTeX / D3 stacks via dynamic imports would help.
174
  - **`addToolOutput` typing**: the ai-sdk v6 `ChatAddToolOutputFunction` is a generic over the tool name union. We currently cast to a plain signature at the two call sites because we don't export a typed tool registry yet.
175
  - **`backend/src/publisher/html-renderer.ts` is ~1000 LOC**: a per-node-type registry would make it more maintainable.
backend/.env.example CHANGED
@@ -1,7 +1,93 @@
1
- # OAuth - Create an app at https://huggingface.co/settings/connected-applications
2
- # Set redirect URI to: http://localhost:8080/auth/callback
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3
  OAUTH_CLIENT_ID=
4
  OAUTH_CLIENT_SECRET=
5
 
6
- # AI model routing
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  OPENROUTER_API_KEY=
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # =============================================================================
2
+ # Research Article Template Editor - Backend environment variables
3
+ # =============================================================================
4
+ # Copy this file to backend/.env and fill in the values you need.
5
+ # All variables are optional unless stated otherwise.
6
+ # See docs/SPECIFICATION.md §6.3 for the full reference.
7
+
8
+ # -----------------------------------------------------------------------------
9
+ # Server
10
+ # -----------------------------------------------------------------------------
11
+
12
+ # HTTP listen port. Defaults to 8080.
13
+ # PORT=8080
14
+
15
+ # Where documents, uploads and published bundles are stored on disk.
16
+ # Defaults to ./data (relative to the backend cwd).
17
+ # DATA_DIR=./data
18
+
19
+ # Toggle Playwright-based PDF + thumbnail generation. Set to "false" to disable
20
+ # PDF export (useful in environments without a browser).
21
+ # ENABLE_PDF=true
22
+
23
+ # -----------------------------------------------------------------------------
24
+ # Hugging Face OAuth (login + write-access check)
25
+ # -----------------------------------------------------------------------------
26
+ # Required to enforce auth on the editor. When OAUTH_CLIENT_ID and
27
+ # OAUTH_CLIENT_SECRET are unset, OAuth is disabled and every visitor can edit
28
+ # (useful for local dev).
29
+ #
30
+ # Create an OAuth app at https://huggingface.co/settings/connected-applications
31
+ # and set the redirect URI to:
32
+ # - http://localhost:8080/auth/callback (local dev)
33
+ # - https://<SPACE_HOST>/auth/callback (HF Space)
34
+
35
  OAUTH_CLIENT_ID=
36
  OAUTH_CLIENT_SECRET=
37
 
38
+ # Space-scoped OAuth requires "manage-repos" to read/write the dataset that
39
+ # backs persistence. Defaults to "openid profile" when unset, which is enough
40
+ # for login-only flows but cannot persist documents to a HF dataset.
41
+ # OAUTH_SCOPES=openid profile manage-repos
42
+
43
+ # -----------------------------------------------------------------------------
44
+ # HF Space context (auto-injected by HF Spaces, set manually for local dev)
45
+ # -----------------------------------------------------------------------------
46
+
47
+ # Identifies the Space (e.g. "tfrere/research-article-template-editor").
48
+ # Auto-set by HF Spaces. Drives the default dataset id and enables OAuth +
49
+ # secure cookies in production. Leave empty for local dev.
50
+ # SPACE_ID=
51
+
52
+ # HTTPS host of the deployed Space (e.g. "tfrere-research-article-template-editor.hf.space").
53
+ # Auto-set by HF Spaces. Used to build the OAuth redirect URI.
54
+ # SPACE_HOST=
55
+
56
+ # -----------------------------------------------------------------------------
57
+ # Hugging Face dataset persistence
58
+ # -----------------------------------------------------------------------------
59
+ # Documents (.yjs), uploads and published bundles are pushed to a HF dataset so
60
+ # they survive Space restarts.
61
+
62
+ # Override the dataset id. Defaults to "${SPACE_ID}-data" when SPACE_ID is set.
63
+ # Example: HF_DATASET_ID=tfrere/research-article-template-editor-data
64
+ # HF_DATASET_ID=
65
+
66
+ # Server-side fallback HF token. Used when no user OAuth token is present yet
67
+ # (e.g. before the first login). Optional - the editor caches the last
68
+ # authenticated user's OAuth token for background dataset writes.
69
+ # Generate one at https://huggingface.co/settings/tokens with "Write" scope.
70
+ # HF_TOKEN=
71
+
72
+ # -----------------------------------------------------------------------------
73
+ # AI features (chat panel + embed studio)
74
+ # -----------------------------------------------------------------------------
75
+ # Required to enable the AI assistant. The chat panel and embed studio are
76
+ # disabled silently when OPENROUTER_API_KEY is unset.
77
+ # Get a key at https://openrouter.ai/keys
78
+
79
  OPENROUTER_API_KEY=
80
+
81
+ # Override the default model id used by the chat agent. The list of supported
82
+ # models is in backend/src/agent/chat.ts (AVAILABLE_MODELS).
83
+ # Defaults to "anthropic/claude-sonnet-4".
84
+ # OPENROUTER_MODEL=anthropic/claude-sonnet-4
85
+
86
+ # -----------------------------------------------------------------------------
87
+ # Publishing
88
+ # -----------------------------------------------------------------------------
89
+
90
+ # Absolute base URL injected into the published HTML (canonical URL,
91
+ # OpenGraph, etc.). Defaults to http://127.0.0.1:${PORT}.
92
+ # Example: PUBLISH_BASE_URL=https://tfrere-research-article-template-editor.hf.space
93
+ # PUBLISH_BASE_URL=
frontend/src/editor/agent-undo-batch.ts ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type { UndoManager } from "yjs";
2
+
3
+ /**
4
+ * Helpers that bracket a sequence of AI-agent edits so they collapse into a
5
+ * single undo step.
6
+ *
7
+ * Yjs UndoManager merges successive transactions that happen within the
8
+ * `captureTimeout` window (500ms by default). `stopCapturing()` forces the
9
+ * next transaction to start a fresh undo item, which we use as a *boundary
10
+ * marker*:
11
+ *
12
+ * - `startAgentBatch()` is called once at the first tool call of a turn.
13
+ * It separates the previous user edit from the agent batch.
14
+ * - `endAgentBatch()` is called when the model finishes streaming.
15
+ * It separates the agent batch from the user's next edits.
16
+ *
17
+ * Between the two markers, every Yjs-backed mutation issued by the agent
18
+ * (replaceSelection, applyDiff, updateFrontmatter, ...) merges into one
19
+ * undo item so a single `Cmd+Z` reverts the entire turn.
20
+ *
21
+ * The functions are no-ops when the manager is unavailable (early in the
22
+ * editor lifecycle) or when the batch is already (in)active, which keeps
23
+ * call sites trivial.
24
+ */
25
+ export interface AgentBatchHandle {
26
+ startAgentBatch(): void;
27
+ endAgentBatch(): void;
28
+ isActive(): boolean;
29
+ }
30
+
31
+ export function createAgentBatch(
32
+ undoManager: UndoManager | null | undefined,
33
+ ): AgentBatchHandle {
34
+ let active = false;
35
+
36
+ return {
37
+ startAgentBatch() {
38
+ if (undoManager && !active) {
39
+ undoManager.stopCapturing();
40
+ active = true;
41
+ }
42
+ },
43
+ endAgentBatch() {
44
+ if (undoManager && active) {
45
+ undoManager.stopCapturing();
46
+ active = false;
47
+ }
48
+ },
49
+ isActive() {
50
+ return active;
51
+ },
52
+ };
53
+ }
frontend/src/hooks/useAgentChat.ts CHANGED
@@ -9,6 +9,7 @@ import type { UndoManager } from "yjs";
9
  import type { UIMessage } from "ai";
10
  import type { FrontmatterStore } from "../editor/frontmatter/frontmatter-store";
11
  import { executeTiptapCommand, TIPTAP_TOOL_NAMES } from "../editor/agent-executor";
 
12
 
13
  interface UseAgentChatOptions {
14
  editor: Editor | null;
@@ -23,7 +24,10 @@ const transport = new DefaultChatTransport({ api: "/api/chat" });
23
 
24
  export function useAgentChat({ editor, undoManager, frontmatterStore, modelRef, initialMessages, onMessagesChange }: UseAgentChatOptions) {
25
  const pendingSelectionRef = useRef<{ from: number; to: number } | null>(null);
26
- const agentBatchActiveRef = useRef(false);
 
 
 
27
  const [input, setInput] = useState("");
28
  const onMessagesChangeRef = useRef(onMessagesChange);
29
  onMessagesChangeRef.current = onMessagesChange;
@@ -53,18 +57,12 @@ export function useAgentChat({ editor, undoManager, frontmatterStore, modelRef,
53
  * merged into one undo step until endAgentBatch() is called.
54
  */
55
  const startAgentBatch = useCallback(() => {
56
- if (undoManager && !agentBatchActiveRef.current) {
57
- undoManager.stopCapturing();
58
- agentBatchActiveRef.current = true;
59
- }
60
- }, [undoManager]);
61
 
62
  const endAgentBatch = useCallback(() => {
63
- if (undoManager && agentBatchActiveRef.current) {
64
- undoManager.stopCapturing();
65
- agentBatchActiveRef.current = false;
66
- }
67
- }, [undoManager]);
68
 
69
  /**
70
  * Extract all text from the ProseMirror document as a flat string,
 
9
  import type { UIMessage } from "ai";
10
  import type { FrontmatterStore } from "../editor/frontmatter/frontmatter-store";
11
  import { executeTiptapCommand, TIPTAP_TOOL_NAMES } from "../editor/agent-executor";
12
+ import { createAgentBatch } from "../editor/agent-undo-batch";
13
 
14
  interface UseAgentChatOptions {
15
  editor: Editor | null;
 
24
 
25
  export function useAgentChat({ editor, undoManager, frontmatterStore, modelRef, initialMessages, onMessagesChange }: UseAgentChatOptions) {
26
  const pendingSelectionRef = useRef<{ from: number; to: number } | null>(null);
27
+ const agentBatchRef = useRef(createAgentBatch(undoManager));
28
+ useEffect(() => {
29
+ agentBatchRef.current = createAgentBatch(undoManager);
30
+ }, [undoManager]);
31
  const [input, setInput] = useState("");
32
  const onMessagesChangeRef = useRef(onMessagesChange);
33
  onMessagesChangeRef.current = onMessagesChange;
 
57
  * merged into one undo step until endAgentBatch() is called.
58
  */
59
  const startAgentBatch = useCallback(() => {
60
+ agentBatchRef.current.startAgentBatch();
61
+ }, []);
 
 
 
62
 
63
  const endAgentBatch = useCallback(() => {
64
+ agentBatchRef.current.endAgentBatch();
65
+ }, []);
 
 
 
66
 
67
  /**
68
  * Extract all text from the ProseMirror document as a flat string,
frontend/tests/agent-undo-batch.test.ts ADDED
@@ -0,0 +1,183 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Agent Undo Batch Tests
3
+ *
4
+ * Covers `docs/TESTS.md` 5.3 - undo batching for AI-agent edits.
5
+ *
6
+ * The agent runs multiple tool calls per turn (replaceSelection, applyDiff,
7
+ * updateFrontmatter, ...). Each tool call triggers one or more Yjs
8
+ * transactions. Without batching, the user would have to press Cmd+Z three
9
+ * times to revert a single agent turn. We bracket the turn with
10
+ * `startAgentBatch()` / `endAgentBatch()` so it collapses into one undo step.
11
+ *
12
+ * These tests target `createAgentBatch` against a real `Y.Doc` +
13
+ * `UndoManager`, mirroring what `useAgentChat` wires at runtime. We don't
14
+ * spin up TipTap or React because the contract being verified ("3
15
+ * collaborative edits inside the batch revert with a single undo()") is
16
+ * purely a Yjs concern.
17
+ */
18
+ import { describe, it, expect } from "vitest";
19
+ import * as Y from "yjs";
20
+ import { createAgentBatch } from "../src/editor/agent-undo-batch";
21
+
22
+ const AGENT_ORIGIN = Symbol("agent");
23
+ const USER_ORIGIN = Symbol("user");
24
+
25
+ interface Fixture {
26
+ ydoc: Y.Doc;
27
+ body: Y.XmlFragment;
28
+ frontmatter: Y.Map<unknown>;
29
+ undoManager: Y.UndoManager;
30
+ batch: ReturnType<typeof createAgentBatch>;
31
+ }
32
+
33
+ function makeFixture(): Fixture {
34
+ const ydoc = new Y.Doc();
35
+ const body = ydoc.getXmlFragment("default");
36
+ const frontmatter = ydoc.getMap("frontmatter");
37
+
38
+ // Mirror `@tiptap/extension-collaboration`'s setup: one UndoManager that
39
+ // watches both the doc body and the frontmatter map, so a single undo()
40
+ // can revert text edits AND metadata mutations together.
41
+ const undoManager = new Y.UndoManager([body, frontmatter], {
42
+ captureTimeout: 500,
43
+ trackedOrigins: new Set([AGENT_ORIGIN, USER_ORIGIN]),
44
+ });
45
+
46
+ return { ydoc, body, frontmatter, undoManager, batch: createAgentBatch(undoManager) };
47
+ }
48
+
49
+ /**
50
+ * Helper that mimics a TipTap text insertion: appends a paragraph element
51
+ * containing the given text to the document body.
52
+ */
53
+ function appendParagraph(ydoc: Y.Doc, body: Y.XmlFragment, text: string, origin: symbol) {
54
+ ydoc.transact(() => {
55
+ const p = new Y.XmlElement("paragraph");
56
+ p.insert(0, [new Y.XmlText(text)]);
57
+ body.push([p]);
58
+ }, origin);
59
+ }
60
+
61
+ /**
62
+ * Helper that mimics agent `applyDiff`: rewrites the text content of a
63
+ * specific paragraph in place.
64
+ */
65
+ function rewriteParagraph(
66
+ ydoc: Y.Doc,
67
+ body: Y.XmlFragment,
68
+ index: number,
69
+ newText: string,
70
+ origin: symbol,
71
+ ) {
72
+ ydoc.transact(() => {
73
+ const p = body.get(index) as Y.XmlElement;
74
+ const t = p.get(0) as Y.XmlText;
75
+ t.delete(0, t.length);
76
+ t.insert(0, newText);
77
+ }, origin);
78
+ }
79
+
80
+ function bodyAsText(body: Y.XmlFragment): string[] {
81
+ const out: string[] = [];
82
+ for (let i = 0; i < body.length; i++) {
83
+ const node = body.get(i) as Y.XmlElement;
84
+ const t = node.get(0) as Y.XmlText;
85
+ out.push(t.toString());
86
+ }
87
+ return out;
88
+ }
89
+
90
+ describe("agent-undo-batch — primitive contract", () => {
91
+ it("startAgentBatch + endAgentBatch are idempotent", () => {
92
+ const { batch } = makeFixture();
93
+
94
+ expect(batch.isActive()).toBe(false);
95
+ batch.startAgentBatch();
96
+ expect(batch.isActive()).toBe(true);
97
+
98
+ // Calling start again is a no-op.
99
+ batch.startAgentBatch();
100
+ expect(batch.isActive()).toBe(true);
101
+
102
+ batch.endAgentBatch();
103
+ expect(batch.isActive()).toBe(false);
104
+
105
+ // Calling end again is a no-op.
106
+ batch.endAgentBatch();
107
+ expect(batch.isActive()).toBe(false);
108
+ });
109
+
110
+ it("is a noop when the UndoManager is null", () => {
111
+ const batch = createAgentBatch(null);
112
+ expect(() => batch.startAgentBatch()).not.toThrow();
113
+ expect(() => batch.endAgentBatch()).not.toThrow();
114
+ expect(batch.isActive()).toBe(false);
115
+ });
116
+ });
117
+
118
+ describe("agent-undo-batch — 5.3.1 batched undo (P0)", () => {
119
+ it("reverts 3 chained agent edits with a single undo()", () => {
120
+ const { ydoc, body, frontmatter, undoManager, batch } = makeFixture();
121
+
122
+ // Simulate a prior user edit that should NOT be touched by the agent's
123
+ // single undo step. We give it a separate origin so its capture window
124
+ // is closed before the agent batch starts.
125
+ appendParagraph(ydoc, body, "User wrote this first.", USER_ORIGIN);
126
+ expect(bodyAsText(body)).toEqual(["User wrote this first."]);
127
+
128
+ // --- Agent turn ---------------------------------------------------------
129
+ batch.startAgentBatch();
130
+
131
+ // Tool 1: replaceSelection-style rewrite of the existing paragraph.
132
+ rewriteParagraph(ydoc, body, 0, "User wrote this first. (refined by agent)", AGENT_ORIGIN);
133
+
134
+ // Tool 2: insertAtCursor-style append of a new paragraph.
135
+ appendParagraph(ydoc, body, "Agent added a follow-up.", AGENT_ORIGIN);
136
+
137
+ // Tool 3: updateFrontmatter-style metadata change.
138
+ ydoc.transact(() => {
139
+ frontmatter.set("title", "New title from agent");
140
+ }, AGENT_ORIGIN);
141
+
142
+ batch.endAgentBatch();
143
+
144
+ // Sanity check: all three changes landed.
145
+ expect(bodyAsText(body)).toEqual([
146
+ "User wrote this first. (refined by agent)",
147
+ "Agent added a follow-up.",
148
+ ]);
149
+ expect(frontmatter.get("title")).toBe("New title from agent");
150
+
151
+ // --- Undo once ----------------------------------------------------------
152
+ undoManager.undo();
153
+
154
+ // All three agent edits revert together; the user paragraph stays.
155
+ expect(bodyAsText(body)).toEqual(["User wrote this first."]);
156
+ expect(frontmatter.get("title")).toBeUndefined();
157
+ });
158
+
159
+ it("does not lump the previous user edit into the agent step", () => {
160
+ const { ydoc, body, undoManager, batch } = makeFixture();
161
+
162
+ // Two distinct user edits, then an agent batch.
163
+ appendParagraph(ydoc, body, "First user paragraph.", USER_ORIGIN);
164
+ appendParagraph(ydoc, body, "Second user paragraph.", USER_ORIGIN);
165
+
166
+ batch.startAgentBatch();
167
+ appendParagraph(ydoc, body, "Agent paragraph.", AGENT_ORIGIN);
168
+ batch.endAgentBatch();
169
+
170
+ expect(bodyAsText(body)).toEqual([
171
+ "First user paragraph.",
172
+ "Second user paragraph.",
173
+ "Agent paragraph.",
174
+ ]);
175
+
176
+ // First undo: only the agent edit reverts.
177
+ undoManager.undo();
178
+ expect(bodyAsText(body)).toEqual([
179
+ "First user paragraph.",
180
+ "Second user paragraph.",
181
+ ]);
182
+ });
183
+ });