Agent 主动关怀系统:让 AI 电子伴侣从被动回复走向主动陪伴
AI 电子伴侣如果永远只在用户发消息之后才回复,本质上还是一个聊天工具。真正的伴侣感,来自它能在合适的时刻主动出现:早安、晚安、久未聊天后的轻轻问候、压力期的一句陪伴、关系升温时的自然靠近。
这篇文章我们把这次主动关怀系统的实现思路和落地细节梳理一下。MVP 阶段不直接做定时推送,而是先打通核心闭环:
1配置主动关怀计划2-> 手动生成一条关怀消息3-> 写入真实聊天历史4-> 首页聊天列表显示最新消息和未读状态5-> 用户打开聊天后标记已读
这个闭环打通之后,后续接 Cloudflare Cron、站内通知、浏览器通知或 LLM 润色,都是在已有模型上继续扩展。
这一阶段先实现一条最小闭环。每个用户的每个 Agent 都可以拥有一份主动关怀计划,可以配置是否开启、频率、偏好时间、关怀场景、语气强度和自定义关怀文案。用户也可以在 Agent 详情页手动生成一条主动关怀消息,这条消息会作为 assistant 消息写入 agent_conversation_messages,同时写入 agent_care_events 用于记录主动关怀事件和未读状态。首页 Agent 列表会把主动关怀消息作为最新回复展示,用户打开聊天窗口后,未读关怀事件会自动标记为已读。
暂时不做自动定时生成、浏览器通知、邮件、短信、微信等外部推送,也不做 LLM 动态生成关怀文案。
原因很简单:主动关怀的第一步不是自动化,而是先证明这条消息能进入聊天系统,能被看见,能被追踪,能和现有聊天历史、首页列表、未读状态形成一个完整闭环。
主动关怀系统由四层组成:
01flowchart TD02A["Agent 详情页 UI"] --> B["Web API Client"]03B --> C["API Routes: /rpc/agent/my/:agentId/care-*"]04C --> D["Repository"]05D --> E["D1: agent_care_plans"]06D --> F["D1: agent_care_events"]07C --> G["agent_conversation_messages"]08C --> H["user_agent_companions.last_assistant_message"]09I["首页 Inbox"] --> H10I --> F11J["聊天窗口"] --> G12J --> F
关键设计点是:主动关怀不是独立通知系统,而是聊天系统的一种消息来源。
所以生成主动关怀时,不只写 agent_care_events,还必须写入 agent_conversation_messages。这样它才能自然出现在聊天历史里,也能被后续记忆、摘要、反馈、回复策略等系统复用。
迁移文件是:
1apps/api/migrations/0015_agent_proactive_care.sql
新增两张表。
agent_care_plans 保存用户对某个 Agent 的主动关怀配置。
01CREATE TABLE IF NOT EXISTS agent_care_plans (02id TEXT PRIMARY KEY,03user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,04agent_id TEXT NOT NULL REFERENCES user_agent_companions(id) ON DELETE CASCADE,05enabled INTEGER NOT NULL,06frequency TEXT NOT NULL,07preferred_time TEXT,08scenes_json TEXT NOT NULL,09tone TEXT NOT NULL,10custom_prompt TEXT,11next_run_at_ms INTEGER,12created_at_ms INTEGER NOT NULL,13updated_at_ms INTEGER NOT NULL14);1516CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_care_plans_user_agent_unique17ON agent_care_plans(user_id, agent_id);1819CREATE INDEX IF NOT EXISTS idx_agent_care_plans_next_run20ON agent_care_plans(enabled, next_run_at_ms);
这些字段里,enabled 表示是否开启主动关怀,frequency 表示关怀频率,目前支持 daily、weekly、custom。preferred_time 保存用户希望收到关怀的大致时间,例如 21:30;scenes_json 用 JSON 存储关怀场景数组,例如 ["long_absence","night"];tone 表示语气强度,可以是轻松、温柔或亲密;custom_prompt 保存用户自定义的关怀提示;next_run_at_ms 表示下一次应该触发的时间,当前 MVP 先计算并保存,后续给 Cron 使用。
唯一索引 idx_agent_care_plans_user_agent_unique 很重要,它保证一个用户对一个 Agent 只有一份关怀计划。
agent_care_events 保存每一次主动关怀生成记录。
01CREATE TABLE IF NOT EXISTS agent_care_events (02id TEXT PRIMARY KEY,03user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,04agent_id TEXT NOT NULL REFERENCES user_agent_companions(id) ON DELETE CASCADE,05care_plan_id TEXT REFERENCES agent_care_plans(id) ON DELETE SET NULL,06conversation_id TEXT NOT NULL REFERENCES agent_conversations(id) ON DELETE CASCADE,07message_id TEXT NOT NULL REFERENCES agent_conversation_messages(id) ON DELETE CASCADE,08scene TEXT NOT NULL,09status TEXT NOT NULL,10message TEXT NOT NULL,11metadata_json TEXT,12generated_at_ms INTEGER NOT NULL,13read_at_ms INTEGER14);1516CREATE INDEX IF NOT EXISTS idx_agent_care_events_agent_generated17ON agent_care_events(user_id, agent_id, generated_at_ms);1819CREATE INDEX IF NOT EXISTS idx_agent_care_events_message20ON agent_care_events(message_id);
这里最关键的是 conversation_id 和 message_id。
它们把主动关怀事件和真实聊天消息绑定起来。也就是说,主动关怀不是外部通知,而是一条真正进入会话的 assistant 消息。
status 当前有两种语义:
1generated: 已生成,但用户还没有打开聊天读取2read: 用户已经打开聊天窗口,事件已读
Contracts 放在:
1packages/contracts/src/agent/my-summary.contract.ts
为了避免前后端随便传字符串,主动关怀的场景、频率、语气都用 Zod enum 收口。
01export const AgentCareSceneSchema = z.enum([02'morning',03'night',04'long_absence',05'stress_support',06'relationship_warmup',07'anniversary',08])0910export const AgentCareFrequencySchema = z.enum(['daily', 'weekly', 'custom'])1112export const AgentCareToneSchema = z.enum(['light', 'gentle', 'intimate'])
关怀计划结构:
01export const AgentCarePlanSchema = z.object({02id: z.string().min(1),03agentId: z.string().min(1),04enabled: z.boolean(),05frequency: AgentCareFrequencySchema,06preferredTime: z.string().max(20).nullable(),07scenes: z.array(AgentCareSceneSchema).min(1).max(6),08tone: AgentCareToneSchema,09customPrompt: z.string().max(800).nullable(),10nextRunAtMs: z.number().int().nonnegative().nullable(),11createdAtMs: z.number().int().nonnegative(),12updatedAtMs: z.number().int().nonnegative(),13})
保存计划时,前端提交的是 UpsertAgentCarePlanRequestSchema:
1export const UpsertAgentCarePlanRequestSchema = z.object({2enabled: z.boolean(),3frequency: AgentCareFrequencySchema,4preferredTime: z.string().trim().max(20).optional().nullable(),5scenes: z.array(AgentCareSceneSchema).min(1).max(6),6tone: AgentCareToneSchema,7customPrompt: z.string().trim().max(800).optional().nullable(),8})
生成事件时只需要可选传入一个场景:
1export const GenerateAgentCareEventRequestSchema = z.object({2scene: AgentCareSceneSchema.optional(),3})
如果不传 scene,后端会取计划中的第一个场景;如果计划也没有可用场景,就退回 long_absence。
Repository 放在:
1apps/api/src/auth/repository.ts
主动关怀相关函数主要有五类。
01export async function findAgentCarePlan(params: {02db: ApiDb03userId: string04agentId: string05}) {06const row = await params.db07.select({08id: agentCarePlans.id,09agentId: agentCarePlans.agentId,10enabled: agentCarePlans.enabled,11frequency: agentCarePlans.frequency,12preferredTime: agentCarePlans.preferredTime,13scenesJson: agentCarePlans.scenesJson,14tone: agentCarePlans.tone,15customPrompt: agentCarePlans.customPrompt,16nextRunAtMs: agentCarePlans.nextRunAtMs,17createdAtMs: agentCarePlans.createdAtMs,18updatedAtMs: agentCarePlans.updatedAtMs,19})20.from(agentCarePlans)21.where(and(22eq(agentCarePlans.userId, params.userId),23eq(agentCarePlans.agentId, params.agentId),24))25.limit(1)26.get()27}
其中 scenes_json 会被解析成数组,并做白名单过滤。
1const allowedScenes = new Set([2'morning',3'night',4'long_absence',5'stress_support',6'relationship_warmup',7'anniversary',8])
这一步很有必要。因为 JSON 字段本身没有 enum 约束,所以读出来时要再做一次数据清洗。
01export async function upsertAgentCarePlan(params: {02db: ApiDb03id: string04userId: string05agentId: string06enabled: boolean07frequency: 'daily' | 'weekly' | 'custom'08preferredTime: string | null09scenes: AgentCareScene[]10tone: 'light' | 'gentle' | 'intimate'11customPrompt: string | null12nextRunAtMs: number | null13nowMs: number14}) {15const existing = await params.db16.select({ id: agentCarePlans.id })17.from(agentCarePlans)18.where(and(19eq(agentCarePlans.userId, params.userId),20eq(agentCarePlans.agentId, params.agentId),21))22.limit(1)23.get()2425if (existing) {26await params.db.update(agentCarePlans).set({27enabled: params.enabled ? 1 : 0,28frequency: params.frequency,29preferredTime: params.preferredTime,30scenesJson: JSON.stringify(params.scenes),31tone: params.tone,32customPrompt: params.customPrompt,33nextRunAtMs: params.nextRunAtMs,34updatedAtMs: params.nowMs,35})3637return existing.id38}3940await params.db.insert(agentCarePlans).values({41id: params.id,42userId: params.userId,43agentId: params.agentId,44enabled: params.enabled ? 1 : 0,45frequency: params.frequency,46preferredTime: params.preferredTime,47scenesJson: JSON.stringify(params.scenes),48tone: params.tone,49customPrompt: params.customPrompt,50nextRunAtMs: params.nextRunAtMs,51createdAtMs: params.nowMs,52updatedAtMs: params.nowMs,53})5455return params.id56}
这里没有直接用数据库原生 upsert,而是先查再 update/insert。这样写虽然多了一步,但读起来更直观,也方便保留原来的 createdAtMs。
01export async function insertAgentCareEvent(params: {02db: ApiDb03id: string04userId: string05agentId: string06carePlanId: string | null07conversationId: string08messageId: string09scene: AgentCareScene10message: string11metadataJson?: string | null12nowMs: number13}) {14await params.db.insert(agentCareEvents).values({15id: params.id,16userId: params.userId,17agentId: params.agentId,18carePlanId: params.carePlanId,19conversationId: params.conversationId,20messageId: params.messageId,21scene: params.scene,22status: 'generated',23message: params.message,24metadataJson: params.metadataJson ?? null,25generatedAtMs: params.nowMs,26readAtMs: null,27})28}
默认状态是 generated,readAtMs 为空,表示还没被用户读取。
Agent 详情页需要展示最近生成的关怀记录,所以提供了 listAgentCareEvents。
01const rows = await params.db02.select({03id: agentCareEvents.id,04agentId: agentCareEvents.agentId,05carePlanId: agentCareEvents.carePlanId,06conversationId: agentCareEvents.conversationId,07messageId: agentCareEvents.messageId,08scene: agentCareEvents.scene,09status: agentCareEvents.status,10message: agentCareEvents.message,11generatedAtMs: agentCareEvents.generatedAtMs,12readAtMs: agentCareEvents.readAtMs,13})14.from(agentCareEvents)15.where(and(16eq(agentCareEvents.userId, params.userId),17eq(agentCareEvents.agentId, params.agentId),18))19.orderBy(sql`${agentCareEvents.generatedAtMs} desc, ${agentCareEvents.id} desc`)20.limit(params.limit)
用户打开聊天窗口时,当前 Agent 的未读关怀事件应该变成已读。
01export async function markAgentCareEventsRead(params: {02db: ApiDb03userId: string04agentId: string05nowMs: number06}) {07try {08await params.db09.update(agentCareEvents)10.set({11status: 'read',12readAtMs: params.nowMs,13})14.where(and(15eq(agentCareEvents.userId, params.userId),16eq(agentCareEvents.agentId, params.agentId),17eq(agentCareEvents.status, 'generated'),18isNull(agentCareEvents.readAtMs),19))20} catch (error) {21console.warn('Agent care read marker is unavailable', error)22}23}
这里用了 try/catch。主动关怀属于聊天体验里的增强能力,即使某个环境还没有跑最新迁移,也不能让聊天历史接口跟着整体崩掉。
路由放在:
1apps/api/src/routes/agent/my.route.ts
新增四个接口:
1GET /rpc/agent/my/:agentId/care-plan2PATCH /rpc/agent/my/:agentId/care-plan3GET /rpc/agent/my/:agentId/care-events4POST /rpc/agent/my/:agentId/care-events/generate
这些路由都要求 Web 用户 access token,并且会校验 Agent 是否属于当前用户。
如果当前 Agent 还没有计划,会自动创建默认计划。
01if (!plan) {02const planId = await upsertAgentCarePlan({03db,04id: uuidv7(),05userId: claims.sub,06agentId,07enabled: false,08frequency: 'daily',09preferredTime: '21:30',10scenes: ['long_absence', 'night'],11tone: 'gentle',12customPrompt: null,13nextRunAtMs: null,14nowMs,15})16}
默认不开启,但给出比较合理的初始配置:
1频率: daily2时间: 21:303场景: 久未聊天、晚安陪伴4语气: gentle
保存时会计算下一次触发时间。
1const nextRunAtMs = calculateNextCareRunAtMs({2enabled: payload.enabled,3frequency: payload.frequency,4preferredTime,5nowMs,6})
计算逻辑是:
01function calculateNextCareRunAtMs(params: {02enabled: boolean03frequency: AgentCareFrequency04preferredTime: string | null05nowMs: number06}) {07if (!params.enabled) {08return null09}1011const next = new Date(params.nowMs)1213if (params.preferredTime) {14const [hourText, minuteText] = params.preferredTime.split(':')15const hour = Number(hourText)16const minute = Number(minuteText)1718if (Number.isFinite(hour) && Number.isFinite(minute)) {19next.setHours(20Math.min(23, Math.max(0, hour)),21Math.min(59, Math.max(0, minute)),220,230,24)25}26}2728if (next.getTime() <= params.nowMs) {29next.setDate(next.getDate() + (params.frequency === 'weekly' ? 7 : 1))30}3132return next.getTime()33}
这个函数现在主要是给后面接入 Cron 做准备。
生成接口是:
1POST /rpc/agent/my/:agentId/care-events/generate
核心流程如下:
01校验用户和 Agent02-> 查找或创建 care plan03-> 选择 scene04-> 获取或创建默认 conversation05-> 生成关怀文案06-> 写入 agent_conversation_messages07-> 写入 agent_care_events08-> 更新 Agent 最新消息09-> 更新 conversation 统计10-> 返回 event
当前这一步没有调用 LLM,而是先使用规则模板生成。
01function buildProactiveCareMessage(params: {02agentName: string03scene: AgentCareScene04tone: AgentCareTone05customPrompt: string | null06}) {07const prefix = getCareTonePrefix(params.tone)08const custom = params.customPrompt?.trim()0910if (custom) {11return `${prefix}。${custom}`.slice(0, 1000)12}1314const templates: Record<AgentCareScene, string> = {15morning: `${prefix},早呀。今天不用一下子把自己推得太紧,先把眼前这一小步走好就可以。`,16night: `${prefix}。今晚先把那些没处理完的事放一放吧,能好好休息,也是一件很重要的事。`,17long_absence: `${prefix},你有一会儿没来了。我没有催你,只是想确认一下你还好不好。`,18stress_support: `${prefix}。如果今天压力有点满,先深呼吸一下,我可以陪你把事情拆小一点。`,19relationship_warmup: `${prefix}。刚才想到你,想留一句话在这里:慢慢来,我会认真听你说。`,20anniversary: `${prefix}。今天像是一个值得被记住的小节点,想陪你把这一刻轻轻收好。`,21}2223return templates[params.scene]24}
这里先不用 LLM,是因为主动关怀需要稳定可用,不应该依赖用户是否配置了第三方 LLM。MVP 阶段我们真正要验证的是数据闭环,而不是把文案写到多精致。等这条链路跑顺以后,再在这个函数内部增加可选的 LLM 润色,也不会影响数据库和 API 的设计。
主动关怀消息生成后,第一步是写入真实会话消息。
01await insertAgentConversationMessage({02db,03id: messageId,04conversationId: conversation.id,05userId: claims.sub,06agentId,07role: 'assistant',08content: message,09status: 'completed',10metadataJson: JSON.stringify({11source: 'proactive_care',12scene,13sceneLabel: getCareSceneLabel(scene),14tone: plan.tone,15}),16nowMs,17})
这里 metadataJson.source = proactive_care 很有价值。后面如果要在 UI 上给主动关怀消息加特殊标识,或者在统计系统里区分用户触发回复和主动关怀消息,就可以直接依赖这个元数据。
然后写入主动关怀事件:
01await insertAgentCareEvent({02db,03id: eventId,04userId: claims.sub,05agentId,06carePlanId: plan.id,07conversationId: conversation.id,08messageId,09scene,10message,11metadataJson: JSON.stringify({12frequency: plan.frequency,13preferredTime: plan.preferredTime,14}),15nowMs,16})
最后更新首页列表会用到的最新消息:
1await updateUserAgentCompanionLatestAssistantMessage({2db,3userId: claims.sub,4agentId,5message,6nowMs,7})
并更新 conversation 统计:
01await updateAgentConversationAfterMessage({02db,03userId: claims.sub,04agentId,05conversationId: conversation.id,06summary: conversation.summary,07messageCount: conversation.messageCount + 1,08lastMessageAtMs: nowMs,09nowMs,10})
这样首页列表不用理解主动关怀这个概念,也能自然展示最新消息。
首页 Inbox 查询在:
1apps/api/src/auth/repository.ts
函数是:
1listUserAgentCompanionsForInbox
最开始可以把未读统计写成主查询里的子查询,但这样有一个风险:如果某个环境还没执行主动关怀迁移,首页 Agent 列表会直接失败。
所以最终实现采用了更稳的方式:先查 Agent 列表,再单独尝试查询未读关怀事件。
01const unreadCareAgentIds = new Set<string>()0203if (rows.length > 0) {04try {05const unreadRows = await db06.select({07agentId: agentCareEvents.agentId,08count: sql<number>`count(*)`,09})10.from(agentCareEvents)11.where(and(12eq(agentCareEvents.userId, userId),13inArray(agentCareEvents.agentId, rows.map((row) => row.id)),14eq(agentCareEvents.status, 'generated'),15isNull(agentCareEvents.readAtMs),16))17.groupBy(agentCareEvents.agentId)1819for (const row of unreadRows) {20if (Number(row.count) > 0) {21unreadCareAgentIds.add(row.agentId)22}23}24} catch (error) {25console.warn('Agent care unread count is unavailable', error)26}27}
最后映射到前端字段:
1hasUnreadCareEvent: unreadCareAgentIds.has(row.id)
前端 Inbox item 里继续使用原来的 unread 字段。
这个设计在工程上会更稳:主动关怀是增强功能,不应该影响 Agent 列表这个主要流程。就算主动关怀表暂时不可用,首页仍然能正常加载。
聊天历史接口在:
1apps/api/src/routes/chat/inbox.route.ts
用户打开某个 Agent 的聊天窗口时,会调用:
1GET /rpc/chat/inbox/:agentId/conversation
这个接口在读取聊天历史前,会标记关怀事件已读:
1await markAgentCareEventsRead({2db,3userId: claims.sub,4agentId,5nowMs: Date.now(),6})
然后继续读取历史消息:
1const messages = await listAgentConversationMessages({2db,3userId: claims.sub,4agentId,5conversationId: conversation.id,6limit: initialHistoryLimit,7})
这样用户打开聊天以后,首页对应 Agent 的未读提示会消失,行为就接近普通聊天软件的新消息已读逻辑。
前端接口封装在:
1apps/web/src/auth/api.ts
新增了四个方法:
01export function getAgentCarePlan(agentId: string) {02return http.get<AgentCarePlanResponse>(`/rpc/agent/my/${agentId}/care-plan`)03}0405export function updateAgentCarePlan(agentId: string, input: UpsertAgentCarePlanRequest) {06return http.patch<AgentCarePlanResponse, UpsertAgentCarePlanRequest>(07`/rpc/agent/my/${agentId}/care-plan`,08input,09)10}1112export function getAgentCareEvents(agentId: string) {13return http.get<AgentCareEventsResponse>(`/rpc/agent/my/${agentId}/care-events`)14}1516export function generateAgentCareEvent(agentId: string, input: GenerateAgentCareEventRequest) {17return http.post<GenerateAgentCareEventResponse, GenerateAgentCareEventRequest>(18`/rpc/agent/my/${agentId}/care-events/generate`,19input,20)21}
这里有一个小细节:保存计划使用 PATCH,因此 API CORS 里也要允许 PATCH。
1allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS']
前端 UI 放在:
1apps/web/app/(dashboard)/agents/detail/_components/agent-detail-client.tsx
主动关怀模块被放在 Agent 详情页的右侧区域。它和 Agent 基本资料编辑是独立状态,避免互相影响。
1const [careForm, setCareForm] = useState<UpsertAgentCarePlanRequest>(defaultCareForm)2const [careErrorMessage, setCareErrorMessage] = useState("")3const [careSuccessMessage, setCareSuccessMessage] = useState("")
加载计划:
1const carePlanQuery = useQuery({2queryKey: ["agent-care-plan", agentId],3queryFn: () => getAgentCarePlan(agentId),4})
加载最近事件:
1const careEventsQuery = useQuery({2queryKey: ["agent-care-events", agentId],3queryFn: () => getAgentCareEvents(agentId),4})
保存计划成功后刷新计划数据:
1await updateAgentCarePlan(agentId, careForm)2await queryClient.invalidateQueries({ queryKey: ["agent-care-plan", agentId] })
手动生成关怀消息后,需要刷新三个地方:
1await generateAgentCareEvent(agentId, { scene })2await queryClient.invalidateQueries({ queryKey: ["agent-care-events", agentId] })3await queryClient.invalidateQueries({ queryKey: ["agent-care-plan", agentId] })4await queryClient.invalidateQueries({ queryKey: ["agent-inbox"] })
这样详情页的最近记录、关怀计划、首页列表都会同步更新。
主动关怀很容易被做成一个通知系统:
1到时间 -> 推送一条通知 -> 用户点击进入
但在 AI 电子伴侣场景里,更合理的抽象是:
1到时间 -> Agent 主动发了一条消息 -> 用户回到聊天里继续关系
这两个抽象差别很大。
如果把它当通知系统,重点会落在推送渠道、点击率、提醒策略。
如果把它当聊天消息,重点会落在关系连续性、上下文、语气、人设、未读和后续回复。
这次实现选择第二种,所以主动关怀必须写入聊天历史。
当前已经在 agent_care_plans 中保存了 next_run_at_ms,所以后面接 Cloudflare Cron 的逻辑会很自然。
伪代码如下:
1Cron 每隔一段时间触发2-> 查询 enabled = 1 且 next_run_at_ms <= now 的 care plans3-> 对每条 plan 生成主动关怀消息4-> 写入 agent_conversation_messages5-> 写入 agent_care_events6-> 更新 last_assistant_message7-> 重新计算 next_run_at_ms
真正接入定时任务时,还要把几件事一起考虑进去。preferred_time 最终应该结合用户时区计算;同一个 Agent 一天最多主动一次,或者按订阅等级限制,避免打扰用户;如果用户刚刚聊过,也不一定需要主动关怀。除此之外,安全边界仍然要生效,如果用户处于高风险状态,主动关怀的语气要更谨慎;生成失败时也不能重复骚扰用户,更不能无限重试。
当前 buildProactiveCareMessage 是模板生成。后面如果要接 LLM,可以保持外层流程不变,只替换文案生成部分。
1plan + agent profile + recent summary + scene2-> LLM 生成候选关怀消息3-> 安全边界检查4-> Reply Quality Guard5-> 写入聊天历史
建议不要让 LLM 自由发挥太多,而是给它明确约束:不要显得像是在监控用户,不要制造情感绑架,不要频繁索取回应,也不要说我一直在等你这类容易造成压力的话。语气还要符合 Agent 人设和当前关系阶段。
主动关怀的质量不是越亲密越好,而是出现得刚刚好。
如果同一个路由文件里已经存在 /:agentId 这种动态路由,那么 /:agentId/care-plan、/:agentId/care-events 这类更具体的路由应该放在动态详情路由之前,避免被提前匹配。
首页列表、聊天历史是主要流程;主动关怀是增强能力。
所以未读统计和已读标记都做了容错处理,避免新表不可用时把首页或聊天窗口拖垮。
只写 agent_care_events 不够。那样它只是后台事件,用户不会在聊天里自然看到,也不能成为后续上下文。
第一版先手动生成,是为了验证模型和 UI。等闭环稳定后,再接 Cron 和通知。
前端保存计划走 PATCH,API CORS 必须包含 PATCH,否则浏览器预检会失败。
这次主动关怀系统主要涉及这些文件:
01apps/api/migrations/0015_agent_proactive_care.sql02apps/api/src/db/schema.ts03apps/api/src/auth/repository.ts04apps/api/src/routes/agent/my.route.ts05apps/api/src/routes/chat/inbox.route.ts06apps/api/src/app.ts07packages/contracts/src/agent/my-summary.contract.ts08packages/contracts/src/index.ts09apps/web/src/auth/api.ts10apps/web/app/(dashboard)/agents/detail/_components/agent-detail-client.tsx11apps/web/app/(dashboard)/agent-chat-proactive-care-system.md
按照项目规则,这里只写建议,不自动执行。
可以这样验证:
agent_care_plans 和 agent_care_events 存在。主动关怀系统的关键不是定时发消息,而是让 AI 电子伴侣具备一种更接近真实聊天关系的主动性。
这次 MVP 选择了比较稳的实现方式:用 agent_care_plans 管配置,用 agent_care_events 管生成记录和已读状态,把主动关怀写入真实聊天历史,再用首页最新消息和未读状态承接用户感知。同时保留 next_run_at_ms,给后面接入 Cron 留好位置。
做到这里,主动关怀就不再是一个孤立功能,而是接入了 Agent 聊天系统的基础设施。后面无论是自动调度、LLM 润色、关系阶段联动,还是通知系统,都可以在这个基础上继续演进。