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 { 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,
|
||||||
|
|
|
||||||
|
|
@ -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, // 只读
|
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 { 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,
|
||||||
|
|
|
||||||
|
|
@ -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} 个已保存的工件`
|
||||||
|
: '暂无已保存的工件',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 { 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue