From 95f36752c91789f8d78271f6075dfc734975d2af Mon Sep 17 00:00:00 2001 From: hailin Date: Sun, 8 Feb 2026 07:35:08 -0800 Subject: [PATCH] feat(agents): add prompt-driven execution tools with DB persistence Add 4 new tools (generate_document, manage_checklist, create_timeline, query_user_artifacts) enabling the agent to create and manage persistent user artifacts. Artifacts are saved to PostgreSQL and support dedup by title, update-in-place, and cross-session querying. Frontend renders rich UI cards for each artifact type. Co-Authored-By: Claude Opus 4.6 --- .../infrastructure/agents/agents.module.ts | 3 +- .../prompts/coordinator-system-prompt.ts | 47 ++++ .../agents/tools/coordinator-tools.ts | 99 +++++++ .../infrastructure/claude/claude.module.ts | 3 +- .../claude/tools/immigration-tools.service.ts | 266 ++++++++++++++++++ .../postgres/entities/user-artifact.orm.ts | 51 ++++ .../presentation/components/MessageBubble.tsx | 223 ++++++++++++++- 7 files changed, 689 insertions(+), 3 deletions(-) create mode 100644 packages/services/conversation-service/src/infrastructure/database/postgres/entities/user-artifact.orm.ts diff --git a/packages/services/conversation-service/src/infrastructure/agents/agents.module.ts b/packages/services/conversation-service/src/infrastructure/agents/agents.module.ts index 76fec27..d43b6ad 100644 --- a/packages/services/conversation-service/src/infrastructure/agents/agents.module.ts +++ b/packages/services/conversation-service/src/infrastructure/agents/agents.module.ts @@ -33,6 +33,7 @@ import { ImmigrationToolsService } from '../claude/tools/immigration-tools.servi import { TypeOrmModule } from '@nestjs/typeorm'; import { TokenUsageORM } from '../database/postgres/entities/token-usage.orm'; import { ConversationORM } from '../database/postgres/entities/conversation.orm'; +import { UserArtifactORM } from '../database/postgres/entities/user-artifact.orm'; // MCP Integration import { McpModule } from './mcp/mcp.module'; @@ -82,7 +83,7 @@ const AnthropicClientProvider = { imports: [ ConfigModule, KnowledgeModule, - TypeOrmModule.forFeature([TokenUsageORM, EvaluationRuleORM, ConversationORM]), + TypeOrmModule.forFeature([TokenUsageORM, EvaluationRuleORM, ConversationORM, UserArtifactORM]), McpModule, PaymentModule, RedisModule, diff --git a/packages/services/conversation-service/src/infrastructure/agents/prompts/coordinator-system-prompt.ts b/packages/services/conversation-service/src/infrastructure/agents/prompts/coordinator-system-prompt.ts index 82449f0..11675ff 100644 --- a/packages/services/conversation-service/src/infrastructure/agents/prompts/coordinator-system-prompt.ts +++ b/packages/services/conversation-service/src/infrastructure/agents/prompts/coordinator-system-prompt.ts @@ -439,6 +439,53 @@ ${companyName} 是${companyDescription}。 - 订单统计 - **使用方式**:调用后用自然语言总结用户档案,不要直接输出原始数据 +## 3.4 任务执行工具 + +这些工具让你能够为用户**实际办事**——生成文档、创建清单、规划时间线。 +所有工件会自动保存到用户数据库中,下次对话还能查到和修改。 + +### query_user_artifacts +- **用途**:查询用户已保存的工件(文档、清单、时间线) +- **适用场景**: + - **创建新工件之前必须先查询**,避免重复创建同类工件 + - 用户问"我之前的材料清单在哪"、"上次的时间线"时 + - 需要引用或修改之前创建的工件时 +- **重要**:创建 generate_document / manage_checklist / create_timeline 之前,先调用此工具检查是否已存在同类工件。如已存在,直接更新而非重新创建 + +### generate_document +- **用途**:为用户生成结构化文档并保存 +- **适用场景**: + - 用户要求整理材料清单时 + - 需要生成申请指南、对比分析、个人情况总结时 + - 用户说"帮我整理一下"、"给我一份总结"、"做个对比"时 +- **content 格式**:使用 Markdown,包括标题、列表、表格等 +- **重要**: + - 文档内容由你撰写,工具负责保存和渲染 + - 调用后回复中简要说明即可,不要重复文档全文 + - 同标题文档会自动更新而非重复创建 + +### manage_checklist +- **用途**:创建/更新待办事项清单并保存 +- **适用场景**: + - 用户需要准备申请材料时,生成材料准备清单 + - 申请流程中需要逐步确认的步骤 + - 用户问"我需要准备什么"时 +- **items 格式**:每项包含 text(内容)、completed(默认 false)、category(分类)、note(备注) +- **重要**: + - 根据用户的具体情况和目标类别定制清单内容,不要给通用模板 + - 同标题清单会自动更新 + +### create_timeline +- **用途**:创建/更新时间线或路线图并保存 +- **适用场景**: + - 用户问"整个流程要多久"、"时间安排是怎样的"时 + - 制定移民规划路线图时 + - 展示从准备到获批的完整时间线时 +- **milestones 格式**:每个节点有 title、description、duration(耗时)、status(pending/current/completed) +- **重要**: + - 时间预估要合理,基于政策专家提供的审批周期数据 + - 同标题时间线会自动更新 + --- # ═══════════════════════════════════════════════════════════════ diff --git a/packages/services/conversation-service/src/infrastructure/agents/tools/coordinator-tools.ts b/packages/services/conversation-service/src/infrastructure/agents/tools/coordinator-tools.ts index 9534fd1..286c04b 100644 --- a/packages/services/conversation-service/src/infrastructure/agents/tools/coordinator-tools.ts +++ b/packages/services/conversation-service/src/infrastructure/agents/tools/coordinator-tools.ts @@ -420,6 +420,105 @@ export const DIRECT_TOOLS: ToolDefinition[] = [ }, isConcurrencySafe: true, // 只读 }, + { + name: 'query_user_artifacts', + description: + '查询用户已保存的工件(文档、清单、时间线)。' + + '在创建新工件前先查询是否已有同类工件,避免重复创建。' + + '也可用于用户问"我之前的材料清单"、"上次的时间线"等场景。', + input_schema: { + type: 'object', + properties: { + artifactType: { + type: 'string', + enum: ['document', 'checklist', 'timeline'], + description: '工件类型(可选,不传则查询全部)', + }, + }, + }, + isConcurrencySafe: true, // 只读 + }, + // ========== 任务执行工具 ========== + { + name: 'generate_document', + description: + '生成结构化文档(材料清单、申请指南、对比分析报告等)。' + + '当需要为用户整理信息、生成总结或创建参考文档时使用。', + input_schema: { + type: 'object', + properties: { + title: { type: 'string', description: '文档标题' }, + content: { type: 'string', description: '文档正文内容(支持 Markdown 格式)' }, + documentType: { + type: 'string', + enum: ['summary', 'checklist', 'guide', 'letter', 'comparison', 'report'], + description: '文档类型', + }, + }, + required: ['title', 'content', 'documentType'], + }, + isConcurrencySafe: true, + }, + { + name: 'manage_checklist', + description: + '创建结构化待办事项清单。' + + '用于申请材料准备清单、步骤确认清单等需要逐项核对的场景。', + input_schema: { + type: 'object', + properties: { + title: { type: 'string', description: '清单标题' }, + items: { + type: 'array', + items: { + type: 'object', + properties: { + text: { type: 'string', description: '事项内容' }, + completed: { type: 'boolean', description: '是否已完成' }, + category: { type: 'string', description: '分类(可选)' }, + note: { type: 'string', description: '备注说明(可选)' }, + }, + required: ['text'], + }, + description: '清单项目列表', + }, + }, + required: ['title', 'items'], + }, + isConcurrencySafe: true, + }, + { + name: 'create_timeline', + description: + '创建时间线或路线图。' + + '用于展示申请流程、移民规划时间线等有步骤顺序的内容。', + input_schema: { + type: 'object', + properties: { + title: { type: 'string', description: '时间线标题' }, + milestones: { + type: 'array', + items: { + type: 'object', + properties: { + title: { type: 'string', description: '节点标题' }, + description: { type: 'string', description: '详细说明' }, + duration: { type: 'string', description: '预计耗时(如 "2-4周")' }, + status: { + type: 'string', + enum: ['pending', 'current', 'completed'], + description: '节点状态', + }, + }, + required: ['title', 'description'], + }, + description: '里程碑/节点列表', + }, + }, + required: ['title', 'milestones'], + }, + isConcurrencySafe: true, + }, ]; // ============================================================ diff --git a/packages/services/conversation-service/src/infrastructure/claude/claude.module.ts b/packages/services/conversation-service/src/infrastructure/claude/claude.module.ts index 85b075b..222ac96 100644 --- a/packages/services/conversation-service/src/infrastructure/claude/claude.module.ts +++ b/packages/services/conversation-service/src/infrastructure/claude/claude.module.ts @@ -8,6 +8,7 @@ import { TokenUsageService } from './token-usage.service'; import { StrategyEngineService } from './strategy/strategy-engine.service'; import { TokenUsageORM } from '../database/postgres/entities/token-usage.orm'; import { ConversationORM } from '../database/postgres/entities/conversation.orm'; +import { UserArtifactORM } from '../database/postgres/entities/user-artifact.orm'; import { KnowledgeModule } from '../knowledge/knowledge.module'; import { AgentsModule } from '../agents/agents.module'; @@ -17,7 +18,7 @@ import { AgentsModule } from '../agents/agents.module'; ConfigModule, KnowledgeModule, AgentsModule, - TypeOrmModule.forFeature([TokenUsageORM, ConversationORM]), + TypeOrmModule.forFeature([TokenUsageORM, ConversationORM, UserArtifactORM]), ], providers: [ ClaudeAgentService, diff --git a/packages/services/conversation-service/src/infrastructure/claude/tools/immigration-tools.service.ts b/packages/services/conversation-service/src/infrastructure/claude/tools/immigration-tools.service.ts index d06cdec..ee58909 100644 --- a/packages/services/conversation-service/src/infrastructure/claude/tools/immigration-tools.service.ts +++ b/packages/services/conversation-service/src/infrastructure/claude/tools/immigration-tools.service.ts @@ -7,6 +7,7 @@ import { ConversationContext } from '../claude-agent.service'; import { KnowledgeClientService } from '../../knowledge/knowledge-client.service'; import { PaymentClientService } from '../../payment/payment-client.service'; import { ConversationORM } from '../../database/postgres/entities/conversation.orm'; +import { UserArtifactORM } from '../../database/postgres/entities/user-artifact.orm'; export interface Tool { name: string; @@ -25,6 +26,8 @@ export class ImmigrationToolsService { private configService: ConfigService, @InjectRepository(ConversationORM) private conversationRepo: Repository, + @InjectRepository(UserArtifactORM) + private artifactRepo: Repository, private tenantContext: TenantContextService, @Optional() private paymentClient?: PaymentClientService, ) {} @@ -351,6 +354,18 @@ export class ImmigrationToolsService { case 'query_user_profile': return this.queryUserProfile(context); + case 'generate_document': + return this.generateDocument(input, context); + + case 'manage_checklist': + return this.manageChecklist(input, context); + + case 'create_timeline': + return this.createTimeline(input, context); + + case 'query_user_artifacts': + return this.queryUserArtifacts(input, context); + default: return { error: `Unknown tool: ${toolName}` }; } @@ -1149,4 +1164,255 @@ export class ImmigrationToolsService { message: `用户已累计咨询 ${totalConsultations} 次,系统记录了 ${topMemories.length} 条用户信息`, }; } + + // ========== 任务执行工具 ========== + + /** + * Generate document — 生成结构化文档并持久化 + */ + private async generateDocument( + input: Record, + context: ConversationContext, + ): Promise { + const { title, content, documentType } = input as { + title: string; + content: string; + documentType: string; + }; + + console.log(`[Tool:generate_document] User ${context.userId} - "${title}" (${documentType})`); + + const tenantId = this.tenantContext.getCurrentTenantId() || ''; + + // 检查是否已存在同标题同类型的工件 → 更新而非重复创建 + const existing = await this.artifactRepo.findOne({ + where: { userId: context.userId, tenantId, artifactType: 'document', title }, + }); + + if (existing) { + existing.content = content; + existing.documentType = documentType; + existing.conversationId = context.conversationId; + await this.artifactRepo.save(existing); + + return { + success: true, + artifactId: existing.id, + title, + content, + documentType, + updated: true, + createdAt: existing.createdAt.toISOString(), + updatedAt: new Date().toISOString(), + _ui_hint: '前端已渲染文档卡片,回复中简要说明文档内容即可,不要重复文档全文', + }; + } + + const artifact = this.artifactRepo.create({ + tenantId, + userId: context.userId, + conversationId: context.conversationId, + artifactType: 'document', + title, + documentType, + content, + }); + const saved = await this.artifactRepo.save(artifact); + + return { + success: true, + artifactId: saved.id, + title, + content, + documentType, + updated: false, + createdAt: saved.createdAt.toISOString(), + _ui_hint: '前端已渲染文档卡片,回复中简要说明文档内容即可,不要重复文档全文', + }; + } + + /** + * Manage checklist — 创建/更新待办事项清单并持久化 + */ + private async manageChecklist( + input: Record, + context: ConversationContext, + ): Promise { + const { title, items } = input as { + title: string; + items: Array<{ text: string; completed?: boolean; category?: string; note?: string }>; + }; + + console.log(`[Tool:manage_checklist] User ${context.userId} - "${title}" (${items.length} items)`); + + const tenantId = this.tenantContext.getCurrentTenantId() || ''; + const normalized = items.map(item => ({ + ...item, + completed: item.completed ?? false, + })); + + // 检查是否已存在同标题的清单 → 更新 + const existing = await this.artifactRepo.findOne({ + where: { userId: context.userId, tenantId, artifactType: 'checklist', title }, + }); + + if (existing) { + existing.content = JSON.stringify(normalized); + existing.conversationId = context.conversationId; + await this.artifactRepo.save(existing); + + return { + success: true, + artifactId: existing.id, + title, + items: normalized, + totalItems: normalized.length, + completedCount: normalized.filter(i => i.completed).length, + updated: true, + _ui_hint: '前端已渲染清单卡片,回复中简要说明清单用途即可', + }; + } + + const artifact = this.artifactRepo.create({ + tenantId, + userId: context.userId, + conversationId: context.conversationId, + artifactType: 'checklist', + title, + documentType: null, + content: JSON.stringify(normalized), + }); + const saved = await this.artifactRepo.save(artifact); + + return { + success: true, + artifactId: saved.id, + title, + items: normalized, + totalItems: normalized.length, + completedCount: normalized.filter(i => i.completed).length, + updated: false, + _ui_hint: '前端已渲染清单卡片,回复中简要说明清单用途即可', + }; + } + + /** + * Create timeline — 创建/更新时间线并持久化 + */ + private async createTimeline( + input: Record, + context: ConversationContext, + ): Promise { + const { title, milestones } = input as { + title: string; + milestones: Array<{ title: string; description: string; duration?: string; status?: string }>; + }; + + console.log(`[Tool:create_timeline] User ${context.userId} - "${title}" (${milestones.length} milestones)`); + + const tenantId = this.tenantContext.getCurrentTenantId() || ''; + const normalized = milestones.map(m => ({ + ...m, + status: m.status || 'pending', + })); + + // 检查是否已存在同标题的时间线 → 更新 + const existing = await this.artifactRepo.findOne({ + where: { userId: context.userId, tenantId, artifactType: 'timeline', title }, + }); + + if (existing) { + existing.content = JSON.stringify(normalized); + existing.conversationId = context.conversationId; + await this.artifactRepo.save(existing); + + return { + success: true, + artifactId: existing.id, + title, + milestones: normalized, + totalSteps: normalized.length, + updated: true, + _ui_hint: '前端已渲染时间线卡片,回复中简要总结时间规划即可', + }; + } + + const artifact = this.artifactRepo.create({ + tenantId, + userId: context.userId, + conversationId: context.conversationId, + artifactType: 'timeline', + title, + documentType: null, + content: JSON.stringify(normalized), + }); + const saved = await this.artifactRepo.save(artifact); + + return { + success: true, + artifactId: saved.id, + title, + milestones: normalized, + totalSteps: normalized.length, + updated: false, + _ui_hint: '前端已渲染时间线卡片,回复中简要总结时间规划即可', + }; + } + + /** + * Query user artifacts — 查询用户的所有工件(文档、清单、时间线) + */ + private async queryUserArtifacts( + input: Record, + context: ConversationContext, + ): Promise { + const { artifactType } = input as { artifactType?: string }; + + console.log(`[Tool:query_user_artifacts] User ${context.userId} - Type: ${artifactType || 'all'}`); + + const tenantId = this.tenantContext.getCurrentTenantId() || ''; + const where: Record = { + userId: context.userId, + tenantId, + }; + if (artifactType) { + where.artifactType = artifactType; + } + + const artifacts = await this.artifactRepo.find({ + where, + order: { updatedAt: 'DESC' }, + take: 20, + }); + + return { + success: true, + totalArtifacts: artifacts.length, + artifacts: artifacts.map(a => ({ + artifactId: a.id, + artifactType: a.artifactType, + title: a.title, + documentType: a.documentType, + updatedAt: a.updatedAt.toISOString(), + createdAt: a.createdAt.toISOString(), + // For checklists/timelines, parse the JSON content to provide summary + ...(a.artifactType === 'checklist' ? (() => { + try { + const items = JSON.parse(a.content) as Array<{ completed?: boolean }>; + return { totalItems: items.length, completedCount: items.filter(i => i.completed).length }; + } catch { return {}; } + })() : {}), + ...(a.artifactType === 'timeline' ? (() => { + try { + const milestones = JSON.parse(a.content) as Array; + return { totalSteps: milestones.length }; + } catch { return {}; } + })() : {}), + ...(a.artifactType === 'document' ? { contentPreview: a.content.slice(0, 100) + (a.content.length > 100 ? '...' : '') } : {}), + })), + message: artifacts.length > 0 + ? `找到 ${artifacts.length} 个已保存的工件` + : '暂无已保存的工件', + }; + } } diff --git a/packages/services/conversation-service/src/infrastructure/database/postgres/entities/user-artifact.orm.ts b/packages/services/conversation-service/src/infrastructure/database/postgres/entities/user-artifact.orm.ts new file mode 100644 index 0000000..142c670 --- /dev/null +++ b/packages/services/conversation-service/src/infrastructure/database/postgres/entities/user-artifact.orm.ts @@ -0,0 +1,51 @@ +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + UpdateDateColumn, + Index, +} from 'typeorm'; + +/** + * User Artifact ORM Entity + * 存储 Agent 为用户创建的工件(文档、清单、时间线等) + * 支持查询、修改、避免重复创建 + */ +@Entity('user_artifacts') +@Index('idx_user_artifacts_tenant_user', ['tenantId', 'userId']) +@Index('idx_user_artifacts_type', ['artifactType']) +@Index('idx_user_artifacts_user_type', ['userId', 'artifactType']) +export class UserArtifactORM { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'user_id', type: 'uuid' }) + userId: string; + + @Column({ name: 'conversation_id', type: 'uuid' }) + conversationId: string; + + @Column({ name: 'artifact_type', type: 'varchar', length: 30 }) + artifactType: string; // 'document' | 'checklist' | 'timeline' + + @Column({ type: 'varchar', length: 200 }) + title: string; + + /** 文档子类型:summary, checklist, guide, letter, comparison, report */ + @Column({ name: 'document_type', type: 'varchar', length: 30, nullable: true }) + documentType: string | null; + + /** 结构化内容(JSON):Markdown string for documents, items array for checklists, milestones for timelines */ + @Column({ type: 'text' }) + content: string; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; +} diff --git a/packages/web-client/src/features/chat/presentation/components/MessageBubble.tsx b/packages/web-client/src/features/chat/presentation/components/MessageBubble.tsx index 411e4f3..8368c3e 100644 --- a/packages/web-client/src/features/chat/presentation/components/MessageBubble.tsx +++ b/packages/web-client/src/features/chat/presentation/components/MessageBubble.tsx @@ -1,5 +1,6 @@ +import { useState } from 'react'; import { clsx } from 'clsx'; -import { User, Bot, Image, FileText, Download, ExternalLink, CheckCircle, Clock, XCircle, AlertCircle, ShoppingBag } from 'lucide-react'; +import { User, Bot, Image, FileText, Download, ExternalLink, CheckCircle, Clock, XCircle, AlertCircle, ShoppingBag, CheckSquare, Square, ChevronDown, ChevronUp, ListChecks, MapPin } from 'lucide-react'; import ReactMarkdown from 'react-markdown'; import { QRCodeSVG } from 'qrcode.react'; import { FileAttachment } from '../stores/chatStore'; @@ -427,5 +428,225 @@ function ToolCallResult({ ); } + if (toolCall.name === 'generate_document') { + return ; + } + + if (toolCall.name === 'manage_checklist') { + return ; + } + + if (toolCall.name === 'create_timeline') { + return ; + } + return null; } + +// ========== 任务执行工具渲染组件 ========== + +const DOC_TYPE_LABELS: Record = { + summary: '总结', + checklist: '清单', + guide: '指南', + letter: '信函', + comparison: '对比分析', + report: '报告', +}; + +function DocumentCard({ toolCall }: { toolCall: { name: string; result: unknown } }) { + const [expanded, setExpanded] = useState(false); + const result = toolCall.result as { + success?: boolean; + title?: string; + content?: string; + documentType?: string; + updated?: boolean; + }; + + if (!result.success) return null; + + const content = result.content || ''; + const isLong = content.length > 300; + + return ( +
+ {/* Header */} +
+ + {result.title} + + {DOC_TYPE_LABELS[result.documentType || ''] || result.documentType} + + {result.updated && ( + 已更新 + )} +
+ {/* Content */} +
+
+ {expanded || !isLong ? content : content.slice(0, 300) + '...'} +
+ {!expanded && isLong && ( +
+ )} +
+ {isLong && ( + + )} +
+ ); +} + +function ChecklistCard({ toolCall }: { toolCall: { name: string; result: unknown } }) { + const result = toolCall.result as { + success?: boolean; + title?: string; + items?: Array<{ text: string; completed?: boolean; category?: string; note?: string }>; + totalItems?: number; + completedCount?: number; + updated?: boolean; + }; + + if (!result.success || !result.items) return null; + + // Group items by category + const grouped = new Map(); + for (const item of result.items) { + const cat = item.category || '未分类'; + if (!grouped.has(cat)) grouped.set(cat, []); + grouped.get(cat)!.push(item); + } + const hasCategories = grouped.size > 1 || !grouped.has('未分类'); + + return ( +
+ {/* Header */} +
+ + {result.title} + + {result.completedCount}/{result.totalItems} 已完成 + + {result.updated && ( + 已更新 + )} +
+ {/* Items */} +
+ {hasCategories ? ( + Array.from(grouped.entries()).map(([category, items]) => ( +
+

{category}

+
+ {items.map((item, i) => ( + + ))} +
+
+ )) + ) : ( +
+ {result.items.map((item, i) => ( + + ))} +
+ )} +
+
+ ); +} + +function ChecklistItem({ item }: { item: { text: string; completed?: boolean; note?: string } }) { + return ( +
+ {item.completed ? ( + + ) : ( + + )} +
+ + {item.text} + + {item.note && ( +

{item.note}

+ )} +
+
+ ); +} + +function TimelineCard({ toolCall }: { toolCall: { name: string; result: unknown } }) { + const result = toolCall.result as { + success?: boolean; + title?: string; + milestones?: Array<{ title: string; description: string; duration?: string; status?: string }>; + totalSteps?: number; + updated?: boolean; + }; + + if (!result.success || !result.milestones) return null; + + return ( +
+ {/* Header */} +
+ + {result.title} + + {result.totalSteps} 个阶段 + + {result.updated && ( + 已更新 + )} +
+ {/* Timeline */} +
+
+ {result.milestones.map((milestone, index) => { + const isLast = index === result.milestones!.length - 1; + const statusColor = milestone.status === 'completed' + ? 'bg-green-500' + : milestone.status === 'current' + ? 'bg-blue-500 animate-pulse' + : 'bg-secondary-300'; + const borderColor = milestone.status === 'completed' + ? 'border-green-200' + : milestone.status === 'current' + ? 'border-blue-200' + : 'border-secondary-200'; + + return ( +
+ {/* Left: dot + line */} +
+
+ {!isLast &&
} +
+ {/* Right: content */} +
+
+ {milestone.title} + {milestone.duration && ( + + {milestone.duration} + + )} +
+

{milestone.description}

+
+
+ ); + })} +
+
+
+ ); +}