Spaces:
Sleeping
Sleeping
| import * as Y from 'yjs' | |
| import * as store from './store.js' | |
| import { newId, b64encode, docNameFor, projectIdOf, pageSlugOf } from './util.js' | |
| import { markdownToBlocks } from './md.js' | |
| import { hocuspocus, buildYBlock, createSuggestion, createPageProposal, deleteDoc } from './collab.js' | |
| const FIELD = 'default' | |
| // A personal test project seeded with every construct the suggestion/comment | |
| // system supports, so the owner can play with accepts/rejects/merges and reset | |
| // back to this exact state at any time. | |
| async function withDoc(docName, fn) { | |
| const conn = await hocuspocus.openDirectConnection(projectIdOf(docName), { user: { username: '__server__' } }) | |
| try { | |
| let result | |
| await conn.transact(document => { | |
| result = fn(document) | |
| }) | |
| return result | |
| } finally { | |
| // see collab.js: an immediate unload races with clients connecting | |
| await conn.disconnect({ unloadImmediately: false }) | |
| } | |
| } | |
| const pageField = docName => (pageSlugOf(docName) === 'home' ? FIELD : `page:${pageSlugOf(docName)}`) | |
| const pageMap = (document, base, docName) => | |
| document.getMap(pageSlugOf(docName) === 'home' ? base : `${base}:${pageSlugOf(docName)}`) | |
| function svgMarkup(label, color) { | |
| return `<svg xmlns="http://www.w3.org/2000/svg" width="480" height="240" viewBox="0 0 480 240"> | |
| <rect width="480" height="240" rx="12" fill="${color}"/> | |
| <polyline points="40,190 120,150 200,168 280,96 360,120 440,60" fill="none" stroke="white" stroke-width="5" stroke-linecap="round"/> | |
| <text x="40" y="46" fill="white" font-family="sans-serif" font-size="22" font-weight="bold">${label}</text> | |
| </svg>` | |
| } | |
| function svgUpload(label, color) { | |
| return '/files/' + store.saveUpload(Buffer.from(svgMarkup(label, color)), 'image/svg+xml') | |
| } | |
| // self-contained data: URI so a sandboxed embed can show it with no network access | |
| function svgDataUri(label, color) { | |
| return 'data:image/svg+xml;base64,' + Buffer.from(svgMarkup(label, color)).toString('base64') | |
| } | |
| function seedPage(docName, markdown) { | |
| return withDoc(docName, document => { | |
| const fragment = document.getXmlFragment(pageField(docName)) | |
| if (fragment.length) fragment.delete(0, fragment.length) | |
| // the doc may still be loaded in memory from before the reset — the file | |
| // deletion alone doesn't clear its maps | |
| for (const mapName of ['suggestions', 'threads']) { | |
| const map = pageMap(document, mapName, docName) | |
| for (const key of [...map.keys()]) map.delete(key) | |
| } | |
| fragment.insert(0, markdownToBlocks(markdown).map(buildYBlock)) | |
| }) | |
| } | |
| // server-side comment thread on a character range of block `blockIdx` | |
| function seedThread(document, docName, { blockIdx, from, to, messages, resolved = false, suggestionId = null }) { | |
| const fragment = document.getXmlFragment(pageField(docName)) | |
| const block = fragment.get(blockIdx) | |
| const text = block?.get(0) | |
| if (!(text instanceof Y.XmlText)) return null | |
| const len = text.length | |
| const a = Math.min(from, len) | |
| const b = Math.min(to, len) | |
| const threadId = newId(10) | |
| const ythread = new Y.Map() | |
| pageMap(document, 'threads', docName).set(threadId, ythread) | |
| ythread.set('anchorStart', b64encode(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(text, a)))) | |
| ythread.set('anchorEnd', b64encode(Y.encodeRelativePosition(Y.createRelativePositionFromTypeIndex(text, b)))) | |
| ythread.set('excerpt', text.toString().slice(a, b)) | |
| ythread.set('resolved', resolved) | |
| if (suggestionId) ythread.set('suggestionId', suggestionId) | |
| const arr = new Y.Array() | |
| ythread.set('messages', arr) | |
| arr.push(messages.map(m => ({ id: newId(10), ts: Date.now(), ...m }))) | |
| return threadId | |
| } | |
| const HOME_MD = `# Playground | |
| This project is a safe sandbox: every kind of suggestion and comment lives here. Accept, reject, undo, merge — then hit **Reset** in the header to restore this exact state. | |
| ## Plain text | |
| The quick brown fox jumps over the lazy dog, and the document keeps calm. This paragraph has a small wording suggestion attached to it. | |
| This second paragraph carries two competing suggestions at once — both stay visible and either one can be accepted. | |
| Formatting survives suggestions: **bold**, *italic*, [links](https://huggingface.co) and \`inline code\` all round-trip. | |
| ## Lists | |
| - alpha | |
| - beta | |
| - gamma | |
| 1. first step | |
| 2. second step | |
| ## Checklist | |
| - [x] task lists render with checkboxes | |
| - [ ] this one has a suggestion attached | |
| ## Table | |
| | Feature | Status | | |
| | --- | --- | | |
| | Suggestions | done | | |
| | Tables | needs a suggestion | | |
| ## Code | |
| \`\`\`python | |
| def greet(name): | |
| message = "Hello, " + name | |
| return message | |
| \`\`\` | |
| > A blockquote to iterate on: this quote has a three-generation revision chain — use the arrows on its card. | |
| ## Live HTML embed | |
| Arbitrary HTML renders in a sandboxed frame — ideal for aligning figures side by side or dropping in an animated explainer. Hover it and hit **Edit HTML** to tweak the markup. It runs with no access to the page, your account, or the network. | |
| \`\`\`html-embed | |
| <style> | |
| body { font-family: system-ui, -apple-system, sans-serif; color: #1a1a1a; } | |
| .row { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } | |
| figure { margin: 0; } | |
| figure img { width: 100%; border-radius: 10px; display: block; } | |
| figcaption { font-size: 13px; color: #666; text-align: center; margin-top: 6px; } | |
| .bar { height: 10px; border-radius: 5px; margin-top: 18px; | |
| background: linear-gradient(90deg, #1d5c45, #7c4a9c, #1d5c45); | |
| background-size: 200% 100%; animation: slide 3s linear infinite; } | |
| @keyframes slide { to { background-position: -200% 0; } } | |
| </style> | |
| <div class="row"> | |
| <figure><img src="__EMBED_IMG1__"><figcaption>Figure A — aligned</figcaption></figure> | |
| <figure><img src="__EMBED_IMG2__"><figcaption>Figure B — aligned</figcaption></figure> | |
| </div> | |
| <div class="bar"></div> | |
| \`\`\` | |
| ## Images | |
| Select the image below to try resizing and alignment. | |
| The paragraph after the images carries an image suggestion. | |
| That is all — happy testing!` | |
| const NOTES_MD = `# Notes | |
| A second page, to try the sidebar, page navigation, and cross-page agent work. | |
| - the structure page defines the tree | |
| - suggest changes to it like any text` | |
| export async function seedPlayground(username, existingId = null) { | |
| const id = existingId || newId(8) | |
| if (existingId) await deleteDoc(existingId) | |
| store.upsertRegistry(id, { | |
| title: 'Playground', | |
| createdBy: username, | |
| createdAt: Date.now(), | |
| updatedAt: Date.now(), | |
| playground: true, | |
| pages: {}, | |
| }) | |
| const img1 = svgUpload('sample figure', '#1d5c45') | |
| const img2 = svgUpload('suggested chart', '#7c4a9c') | |
| const home = HOME_MD.replace('Select the image below', `\n\nSelect the image above`) | |
| .replace('__EMBED_IMG1__', svgDataUri('figure A', '#1d5c45')) | |
| .replace('__EMBED_IMG2__', svgDataUri('figure B', '#7c4a9c')) | |
| await seedPage(id, home) | |
| await seedPage(docNameFor(id, 'notes'), NOTES_MD) | |
| store.upsertPageMeta(id, 'notes', { title: 'Notes', createdAt: Date.now() }) | |
| await seedPage(docNameFor(id, '_structure'), '```yaml\n- home\n- notes\n```') | |
| store.upsertPageMeta(id, '_structure', { title: 'Structure', createdAt: Date.now() }) | |
| // block indices in HOME_MD (after image insertion): find dynamically | |
| const blocks = markdownToBlocks(home) | |
| const idxOf = needle => blocks.findIndex(b => JSON.stringify(b).includes(needle)) | |
| const agent = { author: 'demo-agent', authorType: 'agent' } | |
| const mk = (blockIdx, replacement, rationale, extra = {}) => | |
| createSuggestion(id, { blockIndex: blockIdx, replacementMarkdown: replacement, rationale, ...agent, ...extra }) | |
| // 1. small inline rewrite | |
| await mk( | |
| idxOf('quick brown fox'), | |
| 'The quick brown fox leaps over the lazy dog, and the document stays calm. This paragraph has a small wording suggestion attached to it.', | |
| 'Tighter verbs: jumps → leaps, keeps → stays.' | |
| ) | |
| // 2. two competing suggestions on one paragraph | |
| const competing = idxOf('two competing suggestions') | |
| await mk(competing, 'This second paragraph carries two rival suggestions at once — both stay visible and either one can be accepted.', 'Option A: "rival".') | |
| await mk(competing, 'This second paragraph demonstrates overlapping suggestions — both stay visible and either one can be accepted.', 'Option B: "demonstrates overlapping".') | |
| // 3. list: add an item | |
| await mk(idxOf('gamma'), '- alpha\n- beta\n- gamma\n- delta', 'Adds the missing fourth item.') | |
| // task-list suggestion: check off the open item | |
| await mk( | |
| idxOf('this one has a suggestion attached'), | |
| '- [x] task lists render with checkboxes\n- [x] this one has a suggestion attached', | |
| 'Mark the second task as done.' | |
| ) | |
| // table suggestion: fill in the pending status | |
| await mk( | |
| idxOf('needs a suggestion'), | |
| '| Feature | Status |\n| --- | --- |\n| Suggestions | done |\n| Tables | done |', | |
| 'Update the table: tables are done.' | |
| ) | |
| // 4. new section insertion (formatted panel) | |
| await mk( | |
| idxOf('second step'), | |
| '1. first step\n2. second step\n\n## Checklist\n\n- [links](https://huggingface.co) render in panels\n- **bold** does too', | |
| 'Inserts a new section after the numbered list.' | |
| ) | |
| // 5. codeblock rewrite (word diff inside code) | |
| await mk( | |
| idxOf('greet'), | |
| '```python\ndef greet(name):\n message = f"Hello, {name}!"\n return message\n```', | |
| 'Use an f-string and add the exclamation mark.' | |
| ) | |
| // 6. three-generation revision chain on the blockquote | |
| const quoteIdx = idxOf('three-generation') | |
| const s1 = await mk(quoteIdx, '> A blockquote worth iterating on: generation one of the revision chain.', 'First attempt.') | |
| const s2 = await createSuggestion(id, { | |
| supersedes: s1.suggestion.id, | |
| replacementMarkdown: '> A blockquote worth iterating on: generation two, a bit sharper.', | |
| rationale: 'Second attempt — sharper.', | |
| ...agent, | |
| }) | |
| await createSuggestion(id, { | |
| supersedes: s2.suggestion.id, | |
| replacementMarkdown: '> A blockquote worth iterating on: generation three, the keeper. Use ‹ › on the card to compare.', | |
| rationale: 'Third attempt — the keeper.', | |
| ...agent, | |
| }) | |
| // 7. image suggestion | |
| await mk( | |
| idxOf('carries an image suggestion'), | |
| `The paragraph after the images carries an image suggestion.\n\n`, | |
| 'Adds the missing chart below this paragraph.' | |
| ) | |
| // 8. threads: one open with an attached suggestion, one resolved | |
| const attachRes = await withDoc(id, document => { | |
| const openThread = seedThread(document, id, { | |
| blockIdx: idxOf('happy testing'), | |
| from: 0, | |
| to: 16, | |
| messages: [ | |
| { author: username, authorType: 'user', text: 'Can we end on something stronger? @demo-agent' }, | |
| { author: 'demo-agent', authorType: 'agent', text: 'Proposed a punchier closing line — see the related suggestion.' }, | |
| ], | |
| }) | |
| seedThread(document, id, { | |
| blockIdx: idxOf('Formatting survives'), | |
| from: 0, | |
| to: 20, | |
| resolved: true, | |
| messages: [ | |
| { author: username, authorType: 'user', text: 'Do links work in here?' }, | |
| { author: 'demo-agent', authorType: 'agent', text: 'They do — resolved.' }, | |
| ], | |
| }) | |
| return { openThread } | |
| }) | |
| await createSuggestion(id, { | |
| blockIndex: idxOf('happy testing'), | |
| replacementMarkdown: 'That is all — go break things, the Reset button has your back!', | |
| rationale: 'Punchier closing, as requested in the thread.', | |
| originThreadId: attachRes.openThread, | |
| ...agent, | |
| }) | |
| // 9. a proposed NEW page (shows as pending in the sidebar until accepted) | |
| await createPageProposal(id, { | |
| title: 'Proposed Page', | |
| contentMarkdown: | |
| '# Proposed Page\n\nAn agent proposed this whole page in one suggestion. Accept it from the sidebar to create it, or reject it.\n\n- [ ] try accepting me\n\n| Try | Result |\n| --- | --- |\n| Accept | page is created |', | |
| rationale: 'Demonstrates one-step new-page proposals.', | |
| ...agent, | |
| }) | |
| return { id } | |
| } | |
| export function findPlayground(username) { | |
| for (const [id, meta] of Object.entries(store.getRegistry())) { | |
| if (meta.playground && meta.createdBy === username) return id | |
| } | |
| return null | |
| } | |