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:
parent
85c78b0775
commit
95f36752c9
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
- **重要**:
|
||||
- 时间预估要合理,基于政策专家提供的审批周期数据
|
||||
- 同标题时间线会自动更新
|
||||
|
||||
---
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
// ============================================================
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<ConversationORM>,
|
||||
@InjectRepository(UserArtifactORM)
|
||||
private artifactRepo: Repository<UserArtifactORM>,
|
||||
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<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} 个已保存的工件`
|
||||
: '暂无已保存的工件',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 <DocumentCard toolCall={toolCall} />;
|
||||
}
|
||||
|
||||
if (toolCall.name === 'manage_checklist') {
|
||||
return <ChecklistCard toolCall={toolCall} />;
|
||||
}
|
||||
|
||||
if (toolCall.name === 'create_timeline') {
|
||||
return <TimelineCard toolCall={toolCall} />;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue