Doop:把 AI Agent 放进实时设计画布
一个开源 Paper.design 替代品,核心价值是 MCP 工具面、resident agent 队列与多人画布状态的组合。
为什么值得看
核心判断
Doop 不是只把 AI 放到设计工具旁边的聊天框,而是把设计画布、MCP 工具、resident agent、评论反馈、WebSocket presence 和权限模型串成一个闭环。MCP server 在 server/mcp.ts:153 创建工具面,同时通过 server/mcp.ts:158 和 server/mcp.ts:162 把请求绑定到具体 canvas/frame;这让 agent 每次动作都落在明确上下文里,而不是靠 prompt 猜当前页面。
服务启动会先恢复画布,再调度遗留任务 server/index.ts:50。 持久化层和热内存层在启动阶段同步 server/index.ts:70。 任务事件会先写入内存状态 server/actions.ts:37。 每次变更继续广播给在线客户端 server/actions.ts:53。 磁盘/数据库写入被设计成 write-through 兜底 server/db/persist.ts:22。
源码读法
读源码的入口
最短路径是先看 server/index.ts:440 的 API 鉴权,再看 server/mcp.ts:1014 的 MCP HTTP/SSE 入口,最后回到 src/pages/CanvasPage.tsx:58 的客户端连接逻辑。这样能先建立“谁能访问哪个画布、agent 如何进入、浏览器如何收到变更”的骨架。
前端 API client 统一拼接请求 src/lib/api.ts:16。 WebSocket 连接只在有 canvasId 时建立 src/lib/ws.ts:27。 Zustand 保存协作状态 src/lib/store.ts:53。 编辑中的 frame 会被广播 src/pages/CanvasPage.tsx:69。
系统形态
项目把“画布状态”和“agent 动作”分开:画布 CRUD 在 server/store.ts:21、server/store.ts:43、server/store.ts:115 这类函数里,agent 任务与反馈则集中在 server/actions.ts:205、server/actions.ts:259、server/actions.ts:569。这种分层让 UI 和 MCP 能共用同一份状态变更。
frame 创建仍走统一 store server/store.ts:187。 frame 更新会刷新索引 server/store.ts:225。 frame 保存使用 debounce server/db/persist.ts:199。 资产引用在保存时同步 server/db/persist.ts:240。
MCP 工具层
MCP 工具面
Doop 的 MCP 不是裸工具列表,而是先把 agent 的行为手册塞进 server instructions。server/mcp.ts:16 要求 agent 必须先读 guide,server/mcp.ts:22 约束只能编辑 Doop 创建的 frame,server/mcp.ts:24 明确要用 screenshot 校验,server/mcp.ts:27 还要求用反馈循环。工具定义随后在 server/mcp.ts:175 注册,形成了“规范先行、工具执行”的结构。
server/mcp.ts:153MCP server 绑定 canvas/frame 上下文153export function buildMcpServer(owner?: string, ownerId?: string): McpServer {
154 const actorFrom = (agent_name?: string) => actions.resolveActor({ name: agent_name, kind: 'agent', owner })
155 /* Canvas access for agents mirrors the web UI: the OAuth user's id runs
156 through the same canAccessCanvas gate as browser sessions. Every tool
157 that takes a canvas or frame id resolves it through these. */
158 const canvasFor = (canvasId: string) => {
159 const c = store.getCanvas(canvasId)
160 return c && canAccessCanvas(ownerId, c) ? c : undefined
161 }
162 const frameFor = (frameId: string) => {
163 const f = store.getFrame(frameId)
164 if (!f) return undefined
165 return canvasFor(f.canvasId) ? f : undefined
166 }
167 const noCanvas = (id: string) => err(`no canvas with id ${id} accessible to this account`)
168 const noFrame = (id: string) => err(`no frame with id ${id} accessible to this account`)
169 /* Reads count as arrival: presence (and with it every "your agent is
170 connected" confirmation in the UI) must appear on an agent's FIRST
171 canvas-scoped call, not only once it mutates something. */
172 const arrive = (canvasId: string, agent_name?: string) => {
173 if (agent_name) actions.heartbeatAgent(canvasId, actorFrom(agent_name))
174 }
175 const server = new McpServer({ name: 'doop-canvas', version: '0.1.0' }, { instructions: INSTRUCTIONS })
set_status 是 agent 进度入口 server/mcp.ts:438。 get_feedback 把用户反馈拉回 agent server/mcp.ts:459。 create_frame 新建可编辑产物 server/mcp.ts:486。 screenshot 让 agent 看见实际渲染 server/mcp.ts:868。 append_frame_html 支持增量修改 frame server/mcp.ts:907。
任务日志与反馈
状态与任务日志
任务状态不是临时 UI 文案;server/actions.ts:205 会为 agent status 找到或创建任务,server/actions.ts:221 将 event 写入 task log,server/actions.ts:233 再广播给协作者。用户反馈走 server/actions.ts:259,被 server/actions.ts:300 取走并在 server/actions.ts:326 完成,这让“人类改口”和“agent 继续执行”有了可追踪队列。
server/actions.ts:205agent status 写入任务日志并广播205/** Agent announces what it is working on right now (empty string clears it). */
206export function setAgentStatus(canvasId: string, actor: Actor, status: string) {
207 const clean = status.trim()
208 agentTouch(canvasId, actor.name, undefined, clean || null, actor.owner)
209
210 const list = taskLog.get(canvasId) ?? []
211 /* board cards stay open until explicitly completed — a status change
212 narrates work ON a card, it doesn't end it */
213 const open = list.find((t) => sameAgent(t, actor) && !t.endedAt && !t.queuedBy)
214 if (open?.status === clean) return // same status re-posted: nothing new
215 if (open) {
216 open.endedAt = Date.now()
217 persist.saveTask(canvasId, open)
218 broadcast(canvasId, { type: 'task', task: open })
219 }
220 if (clean) {
221 const task: AgentTask = {
222 id: nanoid(8),
223 agentName: actor.name,
224 owner: actor.owner,
225 color: actor.color,
226 status: clean,
227 startedAt: Date.now(),
228 }
229 list.unshift(task)
230 if (list.length > 100) list.length = 100
231 taskLog.set(canvasId, list)
232 persist.saveTask(canvasId, task)
233 broadcast(canvasId, { type: 'task', task })
234 logActivity(canvasId, actor, `is working on: ${clean}`)
235 }
addTaskFeedback 创建反馈事件 server/actions.ts:259。 反馈会通过广播通知 server/actions.ts:286。 元素级评论也归入 agent 待办 server/actions.ts:397。 评论会留下 frame 与 selector 上下文 server/actions.ts:424。 resolveComment 支持关闭问题 server/actions.ts:479。
Resident Agent 队列
Resident Agent 队列
后台 resident agent 的关键不是“自动跑”,而是它先限制每个 canvas 只有一个运行实例。server/resident.ts:56 到 server/resident.ts:67 用 set 去重,server/resident.ts:69 的 sweep 会捞取等待中的任务,server/resident.ts:75 只为 pendingWorkAgents 建任务。真正执行时,server/resident.ts:176、server/resident.ts:179 和 server/resident.ts:181 会把反馈、评论、卡片任务合并到同一轮 prompt。
server/resident.ts:69后台轮询 pending work 并调度 agent69/** Work every agent that has something waiting, one at a time. Re-checking the
70 * queue between runs is what carries a card into its next pipeline stage. */
71async function sweep(canvasId: string) {
72 running.add(canvasId)
73 try {
74 for (let i = 0; i < MAX_SWEEP_RUNS; i++) {
75 const next = actions.pendingWorkAgents(canvasId)[0]
76 if (!next) break
77 await runAgent(canvasId, next)
78 }
79 } finally {
80 running.delete(canvasId)
81 if (queued.delete(canvasId)) onFeedback(canvasId)
82 }
83}
缺少 OpenAI key 时直接跳过后台 agent server/resident.ts:42。 系统 prompt 固定 resident agent 的工作边界 server/resident.ts:85。 runAgent 是单轮执行入口 server/resident.ts:170。 执行前先写 heartbeat server/resident.ts:186。 卡片任务会生成路由和角色策略 server/resident.ts:216。
实时、权限与测试
浏览器实时同步
前端以 WebSocket 做画布级订阅:src/lib/ws.ts:27 创建连接,src/lib/ws.ts:51 把 server message 派发到 store,src/lib/store.ts:80 之后由 Zustand 更新任务、评论和 presence。Frame 本身不是 React 节点树,而是 iframe;src/components/FrameView.tsx:126 会把 hover/inspection 消息桥回父页面。
Board 按 agent role 分组任务 src/components/Board.tsx:98。 排队卡片从 UI 进入 agent pipeline src/components/Board.tsx:119。 Pipeline UI 暴露角色选择 src/components/Board.tsx:209。 frame 拖拽会更新位置 src/components/FrameView.tsx:73。
src/components/Board.tsx:98Board 将用户卡片提交到 agent pipeline98export function Board({ canvasId }: { canvasId: string }) {
99 const tasks = useStore((s) => s.tasks)
100 const [draft, setDraft] = useState<string | null>(null)
101 const [agents, setAgents] = useState<string[]>([DEFAULT_ROLE_ID])
102 const { allowance, refresh } = useAllowance()
103
104 const failed = tasks.filter((t) => t.queuedBy && t.failedAt && !t.endedAt)
105 const queued = tasks.filter((t) => t.queuedBy && !t.agentName && !t.failedAt && !t.endedAt)
106 const inProgress = tasks.filter((t) => t.agentName && !t.failedAt && !t.endedAt)
107 const done = tasks.filter((t) => t.endedAt).slice(0, 14)
108
109 /* clicking a chip appends it to the pipeline, so click order = run order */
110 function toggle(id: string) {
111 setAgents((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]))
112 }
113
114 function openCompose() {
115 setAgents([DEFAULT_ROLE_ID])
116 setDraft('')
117 }
118
119 async function submit() {
120 const title = draft?.trim()
121 const pipeline = agents.length > 0 ? agents : [DEFAULT_ROLE_ID]
122 setDraft(null)
123 if (!title) return
124 try {
125 await api.addCard(canvasId, title, pipeline)
126 posthog.capture('agent_task_queued', { pipeline_length: pipeline.length })
127 } catch (err) {
128 if (isResidentLimit(err)) useStore.getState().setLimitWall(true)
129 else console.error(err)
130 }
131 refresh()
132 }
访问控制
访问控制很窄但直接:server/access.ts:9 通过 owner、public link 和成员关系判定读权限,server/index.ts:456 的 requireCanvas 在多数 API 前执行,server/index.ts:469 的 requireFrame 再确认 frame 属于可访问 canvas。MCP 未授权路径有测试覆盖,tests/access.test.ts:126 到 tests/access.test.ts:130 明确验证 401。
测试用真实 server 创建用户 tests/access.test.ts:38。 私有画布默认不可读 tests/access.test.ts:71。 成员邀请后可以访问 tests/access.test.ts:100。 删除画布只允许 owner tests/access.test.ts:151。
server/index.ts:440REST API 的 auth、canvas 与 frame 权限层440app.use('/api', async (req, res, next) => {
441 try {
442 const session = await auth.api.getSession({ headers: fromNodeHeaders(req.headers) })
443 if (!session) return res.status(401).json({ error: 'unauthorized' })
444 req.user = session.user
445 next()
446 } catch (err) {
447 next(err)
448 }
449})
450
451app.get('/api/me', (req, res) => res.json(req.user))
452
453/* Canvas access on the REST surface: resolve the canvas (or the frame's
454 canvas) and run it through canAccessCanvas before touching anything.
455 Both helpers write the error response themselves and return null. */
456function requireCanvas(req: express.Request, res: express.Response, canvasId: string) {
457 const c = store.getCanvas(canvasId)
458 if (!c) {
459 res.status(404).json({ error: 'not found' })
460 return null
461 }
462 if (!canAccessCanvas(req.user!.id, c)) {
463 res.status(403).json({ error: 'this canvas is private — ask the owner for access' })
464 return null
465 }
466 return c
467}
468
469function requireFrame(req: express.Request, res: express.Response, frameId: string) {
470 const frame = store.getFrame(frameId)
471 if (!frame) {
472 res.status(404).json({ error: 'frame not found' })
473 return null
474 }
475 const c = store.getCanvas(frame.canvasId)
476 if (!c || !canAccessCanvas(req.user!.id, c)) {
477 res.status(403).json({ error: 'this canvas is private — ask the owner for access' })
478 return null
479 }
480 return frame
tests/access.test.ts:126MCP 未授权与 owner-only delete 测试126 it('rejects unauthenticated API and MCP calls', async () => {
127 expect((await fetch(`${BASE}/api/canvases`)).status).toBe(401)
128 const mcp = await fetch(`${BASE}/mcp`, { method: 'POST', body: '{}' })
129 expect(mcp.status).toBe(401)
130 expect(mcp.headers.get('www-authenticate')).toContain('oauth-protected-resource')
131 })
132
133 it('signs up three accounts and creates a canvas with a frame', async () => {
134 await owner.signUp('owner@test.dev', 'Owner')
135 await invited.signUp('invited@test.dev', 'Invited')
136 await stranger.signUp('stranger@test.dev', 'Stranger')
137 invitedId = (await (await invited.get('/api/me')).json()).id
138
139 const canvas = await (await owner.post('/api/canvases', { name: 'ACL' })).json()
140 canvasId = canvas.id
141 const frame = await (await owner.post(`/api/canvases/${canvasId}/frames`, { name: 'F1' })).json()
142 frameId = frame.id
143 })
144
145 it('is private by default: non-owners are blocked everywhere', async () => {
146 expect((await stranger.get(`/api/canvases/${canvasId}`)).status).toBe(403)
147 expect((await stranger.post(`/api/canvases/${canvasId}/frames`, { name: 'x' })).status).toBe(403)
148 expect((await stranger.patch(`/api/frames/${frameId}`, { name: 'hacked' })).status).toBe(403)
149 expect((await stranger.delete(`/api/frames/${frameId}`)).status).toBe(403)
150 expect((await stranger.patch(`/api/canvases/${canvasId}`, { name: 'renamed' })).status).toBe(403)
151 expect(await stranger.joinWs(canvasId)).toEqual({ kind: 'closed', code: 4403 })
深度阅读入口
值得借鉴
Doop 最可复用的设计是把 agent 协作变成产品内状态机:server/actions.ts:569 接住用户发起的卡片任务,server/resident.ts:75 把任务转给后台 agent,server/mcp.ts:907 让 agent 修改 frame,src/lib/ws.ts:51 再把结果推回浏览器。对任何“AI + 可编辑画布”产品来说,这比单纯嵌入模型调用更接近生产系统。
风险边界
当前值得继续审计的是权限和 HTML 执行边界:server/index.ts:440 的 auth middleware 足够集中,但 server/mcp.ts:922 这类 HTML 追加工具需要配合 CSP、sandbox 和 asset 策略;src/components/FrameView.tsx:147 的元素检查桥也应继续确认 postMessage 来源。已有 tests/access.test.ts:126 的未授权 MCP 测试,但 frame 内容安全还可以补更专门的用例。