GitHub Trending · kgoedecke/doop · 2026-08-25

Doop:把 AI Agent 放进实时设计画布

一个开源 Paper.design 替代品,核心价值是 MCP 工具面、resident agent 队列与多人画布状态的组合。

01

为什么值得看

核心判断

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。

101tracked files
15.6kTS/TSX LOC
1,057server/mcp.ts lines
325★search snapshot
Browser UI -> REST/WS server -> Canvas store -> MCP tools -> Agent feedback/cards -> iframe frame updates -> persisted canvas history
02

源码读法

读源码的入口

最短路径是先看 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。

03

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。

04

任务日志与反馈

状态与任务日志

任务状态不是临时 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。

05

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 并调度 agent
69/** 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。

06

实时、权限与测试

浏览器实时同步

前端以 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 pipeline
98export 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 })
07

深度阅读入口

值得借鉴

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 内容安全还可以补更专门的用例。