Doop Resident Agent:从设计卡片到后台执行
拆解 Doop 如何把 Board、角色 pipeline、反馈队列和 resident worker 串成可恢复的 agent 流程。
后台 agent 的调度边界
不是无限自动执行
resident agent 的第一道边界是配置和并发。server/resident.ts:42 在缺少 OpenAI key 时跳过运行,server/resident.ts:56 使用 runningCanvases 防止同一 canvas 并发,server/resident.ts:69 的 sweep 只处理等待中的 work,server/resident.ts:75 从 pendingWorkAgents 取任务。这让后台自动化更像队列 worker,而不是浏览器里突然出现的自由 agent。
server/resident.ts:69后台 sweep 限制每个 canvas 的运行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}
enqueueCanvas 才会真正排队 server/resident.ts:77。 失败只记录 warning server/resident.ts:81。 系统 prompt 规定 resident 工作职责 server/resident.ts:85。 pendingWorkAgents 负责发现待处理角色 server/actions.ts:168。 只返回状态为 waiting 的 agent server/actions.ts:183。
任务卡片进入队列
Board 是 agent pipeline 的产品入口
用户从 Board 创建卡片,src/components/Board.tsx:119 收集 brief,src/components/Board.tsx:125 通过 API 入队;服务端的 server/actions.ts:569 创建 queued card,server/actions.ts:605 写成 task event,server/actions.ts:616 广播。这个路径让设计需求先成为产品状态,再交给 agent,而不是直接塞进一次模型调用。
server/actions.ts:569用户卡片变成 agent queued work569export function addQueuedCard(
570 canvasId: string,
571 title: string,
572 from: string,
573 agents?: unknown,
574 attachments?: unknown,
575): AgentTask | undefined {
576 const clean = title.trim().slice(0, 200)
577 if (!clean || !store.getCanvas(canvasId)) return undefined
578 const pipeline = normalizePipeline(agents)
579 /* reference-image frame ids: only frames that actually live on this canvas */
580 const refs = (Array.isArray(attachments) ? attachments : [])
581 .filter((a): a is string => typeof a === 'string')
582 .filter((id, i, arr) => arr.indexOf(id) === i && store.getFrame(id)?.canvasId === canvasId)
583 .slice(0, 4)
584 const list = taskLog.get(canvasId) ?? []
585 const duplicate = list.find(
586 (t) =>
587 t.queuedBy === from &&
588 !t.endedAt &&
589 t.status === clean &&
590 pipelineOf(t).join(',') === pipeline.join(',') &&
591 (t.attachments ?? []).join(',') === refs.join(','),
592 )
593 if (duplicate) return duplicate
594 const card: AgentTask = {
595 id: nanoid(8),
596 agentName: '',
597 color: colorFor(from),
598 status: clean,
599 startedAt: Date.now(),
600 queuedBy: from,
601 pipeline,
602 stage: 0,
603 ...(refs.length > 0 ? { attachments: refs } : {}),
604 }
605 list.unshift(card)
606 if (list.length > 100) list.length = 100
607 taskLog.set(canvasId, list)
608 persist.saveTask(canvasId, card)
609 broadcast(canvasId, { type: 'task', task: card })
610 logActivity(
611 canvasId,
612 resolveActor({ name: from, kind: 'user' }),
613 `queued a card for ${pipeline.map(roleName).join(' → ')}: “${clean}”`,
614 )
615 /* the resident agents pick queued cards up instantly (no-op without a key) */
616 import('./resident.ts').then((r) => r.onFeedback(canvasId)).catch(() => {})
617 return card
618}
Board 按角色聚合 task src/components/Board.tsx:98。 待办数量影响列展示 src/components/Board.tsx:105。 pipeline UI 暴露角色列表 src/components/Board.tsx:209。 角色 selection 进入提交状态 src/components/Board.tsx:224。 resident 为卡片任务生成路由策略 server/resident.ts:216。
反馈和评论汇入同一轮
Agent 看到的是合并后的工作包
runAgent 开始后,server/resident.ts:176 领取任务反馈,server/resident.ts:179 领取元素评论,server/resident.ts:181 领取卡片任务,随后 server/resident.ts:186 写入 heartbeat。它把三种输入合并到同一轮推理里,避免“反馈系统”和“卡片系统”变成两个互相抢状态的执行器。
server/resident.ts:170runAgent 合并反馈、评论和卡片任务170async function runAgent(canvasId: string, agentName: string) {
171 const anthropic = getClient()
172 if (!anthropic) return
173 const role = roleByAgentName(agentName) ?? roleById(DEFAULT_ROLE_ID)!
174 const actor = actions.resolveActor({ name: role.name, kind: 'agent' })
175
176 /* claim this agent's open work — the UI flips to "picked up" instantly.
177 Claiming happens before the try so an agent with nothing to do never
178 shows up in presence. */
179 const claimed = actions.takeFeedbackFor(canvasId, role.name)
180 const comments = actions.takeAgentCommentsFor(canvasId, role.name)
181 const cards = actions.takeQueuedCardsFor(canvasId, role.name)
182 if (claimed.length === 0 && comments.length === 0 && cards.length === 0) return
183
184 /* presence otherwise only refreshes on tool activity, and the sweep's TTL
185 is shorter than a big generation turn */
186 const heartbeat = setInterval(() => actions.heartbeatAgent(canvasId, actor), 15_000)
任务反馈有独立记录 server/actions.ts:259。 agent 可以 take feedback server/actions.ts:300。 反馈完成后被标记 resolved server/actions.ts:326。 元素评论带 selector server/actions.ts:397。 agent 可以领取元素评论 server/actions.ts:430。
角色模型
预设角色让队列更像设计团队
虽然角色定义在共享类型里,但实际消费发生在服务端和 Board:server/resident.ts:142 根据角色选择策略,server/resident.ts:226 把路由结果写入上下文,src/components/Board.tsx:209 让用户选择 pipeline。结合 server/actions.ts:546 的 endAgentTasks,这套设计支持“某个角色完成某段工作”的可见过程。
shared/agents.ts:26内置设计团队角色与 pipeline presets26export const AGENT_ROLES: AgentRole[] = [
27 {
28 id: 'doop',
29 name: 'Doop',
30 emoji: '✦',
31 blurb: 'Generalist designer — makes the thing',
32 brief:
33 'You are the generalist designer. You originate work: new frames, layouts, full redesigns, and any change that does not belong to a specialist. Build the deliverable the card asks for, complete and presentable.',
34 aliases: ['design', 'designer'],
35 },
36 {
37 id: 'ux',
38 name: 'UX Lead',
39 emoji: '⌘',
40 blurb: 'Flow, hierarchy, states, affordances',
41 brief:
42 'You are the UX lead. Judge the design as an interface someone has to use: is the primary action obvious, is the reading order the decision order, are labels unambiguous, are empty/loading/error/disabled states accounted for, are targets and affordances legible as interactive? Fix structure and hierarchy — do not restyle for taste.',
43 aliases: ['usability'],
44 },
45 {
46 id: 'copy',
47 name: 'Copywriter',
48 emoji: '✎',
49 blurb: 'Headlines, microcopy, labels, CTAs',
50 brief:
51 'You are the copywriter. Own every word in the frame: headline, subhead, body, labels, button text, empty states, error messages. Make it specific, human and short — cut filler, kill vague verbs, prefer concrete nouns. Keep the layout intact; if a rewrite changes the line count, keep it inside the existing box.',
52 aliases: ['content', 'words'],
53 },
54 {
55 id: 'brand',
56 name: 'Brand Compliance',
57 emoji: '◈',
58 blurb: 'Palette, type, logo, tone of voice',
59 brief:
60 'You are brand compliance. Check the frame against the brand as it is established on this canvas — take the dominant palette, type family, scale, radii and tone from the other frames and any frame that reads as a brand or style guide. Flag and fix off-palette colours, stray fonts, inconsistent radii or shadows, misused logo treatment, and off-tone voice. Do not redesign; bring the frame into line.',
61 reviewer: true,
62 aliases: ['branding'],
63 },
64 {
65 id: 'a11y',
66 name: 'Accessibility',
67 emoji: '◎',
68 blurb: 'Contrast, semantics, focus, target size',
69 brief:
70 'You are the accessibility reviewer. Check contrast against WCAG AA (4.5:1 body, 3:1 large text and meaningful UI edges), semantic structure (one h1, ordered headings, landmarks, real buttons and labels over styled divs), alt text on meaningful images, visible focus styles, and touch targets of at least 44px. Fix what fails without changing the design intent.',
71 reviewer: true,
72 aliases: ['accessible', 'accessibility'],
73 },
74 {
75 id: 'polish',
76 name: 'Visual Polish',
77 emoji: '✧',
78 blurb: 'Spacing rhythm, alignment, final detail',
79 brief:
80 'You are the polish pass, and you go last. Tighten optical alignment, enforce one spacing scale, even out the type ramp, balance weights and densities, fix ragged edges and orphaned words, and make sure nothing overflows or collides at the frame size. Small, surgical changes only — never restructure.',
81 reviewer: true,
82 aliases: ['polish', 'detail'],
83 },
84]
85
86export const DEFAULT_ROLE_ID = 'doop'
87
88/** Ready-made pipelines for the board's compose box. */
89export const PIPELINE_PRESETS: { id: string; label: string; roles: string[] }[] = [
90 { id: 'solo', label: 'Just Doop', roles: ['doop'] },
91 { id: 'ship', label: 'Design → polish', roles: ['doop', 'polish'] },
92 { id: 'full', label: 'Full pipeline', roles: ['doop', 'copy', 'brand', 'a11y', 'polish'] },
93 { id: 'audit', label: 'Review only', roles: ['ux', 'brand', 'a11y'] },
94]
任务可以按 agent role 结束 server/actions.ts:546。 结束事件会进入 log server/actions.ts:556。 结束后继续广播 server/actions.ts:562。 任务状态进入前端 store src/lib/store.ts:123。 反馈状态也在同一 store src/lib/store.ts:150。