cowrite-dev / server /playground.js
Agent Manager
Make agent feedback standalone and traceable
26dfc7b
Raw
History Blame Contribute Delete
12.2 kB
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', `![sample figure](${img1})\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![suggested chart](${img2})`,
'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
}