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 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-08 07:35:08 -08:00
parent 85c78b0775
commit 95f36752c9
7 changed files with 689 additions and 3 deletions

View File

@ -33,6 +33,7 @@ import { ImmigrationToolsService } from '../claude/tools/immigration-tools.servi
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { TokenUsageORM } from '../database/postgres/entities/token-usage.orm'; import { TokenUsageORM } from '../database/postgres/entities/token-usage.orm';
import { ConversationORM } from '../database/postgres/entities/conversation.orm'; import { ConversationORM } from '../database/postgres/entities/conversation.orm';
import { UserArtifactORM } from '../database/postgres/entities/user-artifact.orm';
// MCP Integration // MCP Integration
import { McpModule } from './mcp/mcp.module'; import { McpModule } from './mcp/mcp.module';
@ -82,7 +83,7 @@ const AnthropicClientProvider = {
imports: [ imports: [
ConfigModule, ConfigModule,
KnowledgeModule, KnowledgeModule,
TypeOrmModule.forFeature([TokenUsageORM, EvaluationRuleORM, ConversationORM]), TypeOrmModule.forFeature([TokenUsageORM, EvaluationRuleORM, ConversationORM, UserArtifactORM]),
McpModule, McpModule,
PaymentModule, PaymentModule,
RedisModule, RedisModule,

View File

@ -439,6 +439,53 @@ ${companyName} 是${companyDescription}。
- -
- **使** - **使**
## 3.4
****线
### query_user_artifacts
- ****线
- ****
- ****
- "我之前的材料清单在哪""上次的时间线"
-
- **** generate_document / manage_checklist / create_timeline
### generate_document
- ****
- ****
-
-
- "帮我整理一下""给我一份总结""做个对比"
- **content **使 Markdown
- ****
-
-
-
### manage_checklist
- ****/
- ****
-
-
- "我需要准备什么"
- **items ** textcompleted falsecategorynote
- ****
-
-
### create_timeline
- ****/线线
- ****
- "整个流程要多久""时间安排是怎样的"
- 线
- 线
- **milestones ** titledescriptiondurationstatuspending/current/completed
- ****
-
- 线
--- ---
# #

View File

@ -420,6 +420,105 @@ export const DIRECT_TOOLS: ToolDefinition[] = [
}, },
isConcurrencySafe: true, // 只读 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,
},
]; ];
// ============================================================ // ============================================================

View File

@ -8,6 +8,7 @@ import { TokenUsageService } from './token-usage.service';
import { StrategyEngineService } from './strategy/strategy-engine.service'; import { StrategyEngineService } from './strategy/strategy-engine.service';
import { TokenUsageORM } from '../database/postgres/entities/token-usage.orm'; import { TokenUsageORM } from '../database/postgres/entities/token-usage.orm';
import { ConversationORM } from '../database/postgres/entities/conversation.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 { KnowledgeModule } from '../knowledge/knowledge.module';
import { AgentsModule } from '../agents/agents.module'; import { AgentsModule } from '../agents/agents.module';
@ -17,7 +18,7 @@ import { AgentsModule } from '../agents/agents.module';
ConfigModule, ConfigModule,
KnowledgeModule, KnowledgeModule,
AgentsModule, AgentsModule,
TypeOrmModule.forFeature([TokenUsageORM, ConversationORM]), TypeOrmModule.forFeature([TokenUsageORM, ConversationORM, UserArtifactORM]),
], ],
providers: [ providers: [
ClaudeAgentService, ClaudeAgentService,

View File

@ -7,6 +7,7 @@ import { ConversationContext } from '../claude-agent.service';
import { KnowledgeClientService } from '../../knowledge/knowledge-client.service'; import { KnowledgeClientService } from '../../knowledge/knowledge-client.service';
import { PaymentClientService } from '../../payment/payment-client.service'; import { PaymentClientService } from '../../payment/payment-client.service';
import { ConversationORM } from '../../database/postgres/entities/conversation.orm'; import { ConversationORM } from '../../database/postgres/entities/conversation.orm';
import { UserArtifactORM } from '../../database/postgres/entities/user-artifact.orm';
export interface Tool { export interface Tool {
name: string; name: string;
@ -25,6 +26,8 @@ export class ImmigrationToolsService {
private configService: ConfigService, private configService: ConfigService,
@InjectRepository(ConversationORM) @InjectRepository(ConversationORM)
private conversationRepo: Repository<ConversationORM>, private conversationRepo: Repository<ConversationORM>,
@InjectRepository(UserArtifactORM)
private artifactRepo: Repository<UserArtifactORM>,
private tenantContext: TenantContextService, private tenantContext: TenantContextService,
@Optional() private paymentClient?: PaymentClientService, @Optional() private paymentClient?: PaymentClientService,
) {} ) {}
@ -351,6 +354,18 @@ export class ImmigrationToolsService {
case 'query_user_profile': case 'query_user_profile':
return this.queryUserProfile(context); 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: default:
return { error: `Unknown tool: ${toolName}` }; return { error: `Unknown tool: ${toolName}` };
} }
@ -1149,4 +1164,255 @@ export class ImmigrationToolsService {
message: `用户已累计咨询 ${totalConsultations} 次,系统记录了 ${topMemories.length} 条用户信息`, message: `用户已累计咨询 ${totalConsultations} 次,系统记录了 ${topMemories.length} 条用户信息`,
}; };
} }
// ========== 任务执行工具 ==========
/**
* Generate document
*/
private async generateDocument(
input: Record<string, unknown>,
context: ConversationContext,
): Promise<unknown> {
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<string, unknown>,
context: ConversationContext,
): Promise<unknown> {
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<string, unknown>,
context: ConversationContext,
): Promise<unknown> {
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<string, unknown>,
context: ConversationContext,
): Promise<unknown> {
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<string, unknown> = {
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<unknown>;
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} 个已保存的工件`
: '暂无已保存的工件',
};
}
} }

View File

@ -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;
/** 结构化内容JSONMarkdown 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;
}

View File

@ -1,5 +1,6 @@
import { useState } from 'react';
import { clsx } from 'clsx'; 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 ReactMarkdown from 'react-markdown';
import { QRCodeSVG } from 'qrcode.react'; import { QRCodeSVG } from 'qrcode.react';
import { FileAttachment } from '../stores/chatStore'; import { FileAttachment } from '../stores/chatStore';
@ -427,5 +428,225 @@ function ToolCallResult({
); );
} }
if (toolCall.name === 'generate_document') {
return <DocumentCard toolCall={toolCall} />;
}
if (toolCall.name === 'manage_checklist') {
return <ChecklistCard toolCall={toolCall} />;
}
if (toolCall.name === 'create_timeline') {
return <TimelineCard toolCall={toolCall} />;
}
return null; return null;
} }
// ========== 任务执行工具渲染组件 ==========
const DOC_TYPE_LABELS: Record<string, string> = {
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 (
<div className="mt-3 rounded-lg border border-blue-200 overflow-hidden">
{/* Header */}
<div className="flex items-center gap-2 px-4 py-2 bg-blue-50 border-b border-blue-200">
<FileText className="w-4 h-4 text-blue-500" />
<span className="text-sm font-medium text-blue-700">{result.title}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-blue-100 text-blue-600">
{DOC_TYPE_LABELS[result.documentType || ''] || result.documentType}
</span>
{result.updated && (
<span className="text-xs px-1.5 py-0.5 rounded bg-yellow-100 text-yellow-600"></span>
)}
</div>
{/* Content */}
<div className={clsx('px-4 py-3 bg-white', !expanded && isLong && 'max-h-48 overflow-hidden relative')}>
<div className="prose prose-sm max-w-none">
<ReactMarkdown>{expanded || !isLong ? content : content.slice(0, 300) + '...'}</ReactMarkdown>
</div>
{!expanded && isLong && (
<div className="absolute bottom-0 left-0 right-0 h-12 bg-gradient-to-t from-white to-transparent" />
)}
</div>
{isLong && (
<button
onClick={() => setExpanded(!expanded)}
className="w-full flex items-center justify-center gap-1 py-2 text-xs text-blue-500 hover:bg-blue-50 border-t border-blue-100"
>
{expanded ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
{expanded ? '收起' : '展开全文'}
</button>
)}
</div>
);
}
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<string, typeof result.items>();
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 (
<div className="mt-3 rounded-lg border border-green-200 overflow-hidden">
{/* Header */}
<div className="flex items-center gap-2 px-4 py-2 bg-green-50 border-b border-green-200">
<ListChecks className="w-4 h-4 text-green-500" />
<span className="text-sm font-medium text-green-700">{result.title}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-600">
{result.completedCount}/{result.totalItems}
</span>
{result.updated && (
<span className="text-xs px-1.5 py-0.5 rounded bg-yellow-100 text-yellow-600"></span>
)}
</div>
{/* Items */}
<div className="px-4 py-3 bg-white space-y-3">
{hasCategories ? (
Array.from(grouped.entries()).map(([category, items]) => (
<div key={category}>
<p className="text-xs font-medium text-secondary-500 mb-1.5">{category}</p>
<div className="space-y-1.5">
{items.map((item, i) => (
<ChecklistItem key={i} item={item} />
))}
</div>
</div>
))
) : (
<div className="space-y-1.5">
{result.items.map((item, i) => (
<ChecklistItem key={i} item={item} />
))}
</div>
)}
</div>
</div>
);
}
function ChecklistItem({ item }: { item: { text: string; completed?: boolean; note?: string } }) {
return (
<div className="flex items-start gap-2">
{item.completed ? (
<CheckSquare className="w-4 h-4 text-green-500 mt-0.5 flex-shrink-0" />
) : (
<Square className="w-4 h-4 text-secondary-300 mt-0.5 flex-shrink-0" />
)}
<div>
<span className={clsx('text-sm', item.completed && 'line-through text-secondary-400')}>
{item.text}
</span>
{item.note && (
<p className="text-xs text-secondary-400 mt-0.5">{item.note}</p>
)}
</div>
</div>
);
}
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 (
<div className="mt-3 rounded-lg border border-purple-200 overflow-hidden">
{/* Header */}
<div className="flex items-center gap-2 px-4 py-2 bg-purple-50 border-b border-purple-200">
<MapPin className="w-4 h-4 text-purple-500" />
<span className="text-sm font-medium text-purple-700">{result.title}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-purple-100 text-purple-600">
{result.totalSteps}
</span>
{result.updated && (
<span className="text-xs px-1.5 py-0.5 rounded bg-yellow-100 text-yellow-600"></span>
)}
</div>
{/* Timeline */}
<div className="px-4 py-3 bg-white">
<div className="relative">
{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 (
<div key={index} className="flex gap-3">
{/* Left: dot + line */}
<div className="flex flex-col items-center">
<div className={clsx('w-3 h-3 rounded-full flex-shrink-0 mt-1', statusColor)} />
{!isLast && <div className={clsx('w-0.5 flex-1 my-1', statusColor === 'bg-secondary-300' ? 'bg-secondary-200' : 'bg-green-200')} />}
</div>
{/* Right: content */}
<div className={clsx('pb-4 flex-1', isLast && 'pb-0')}>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{milestone.title}</span>
{milestone.duration && (
<span className={clsx('text-xs px-1.5 py-0.5 rounded border', borderColor)}>
{milestone.duration}
</span>
)}
</div>
<p className="text-xs text-secondary-500 mt-0.5">{milestone.description}</p>
</div>
</div>
);
})}
</div>
</div>
</div>
);
}