400 lines
16 KiB
TypeScript
400 lines
16 KiB
TypeScript
import { Anthropic } from "@anthropic-ai/sdk";
|
||
import {
|
||
Message,
|
||
MessageParam,
|
||
Tool
|
||
} from "@anthropic-ai/sdk/resources/messages/messages.mjs";
|
||
import { ComponentService } from '@/lib/services/component-service';
|
||
import { ConversationService } from '@/lib/services/conversation-service';
|
||
|
||
|
||
const ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
|
||
const ANTHROPIC_BASE_URL = process.env.ANTHROPIC_BASE_URL;
|
||
if (!ANTHROPIC_API_KEY) {
|
||
throw new Error("ANTHROPIC_API_KEY is not set");
|
||
}
|
||
|
||
|
||
|
||
export class AIClient {
|
||
private anthropic: Anthropic;
|
||
private tools: Tool[] = [
|
||
{
|
||
name: "query-components",
|
||
description: "查询PC配件信息,支持按类型、品牌、价格范围、关键词等条件搜索。单次最多返回5个配件。",
|
||
input_schema: {
|
||
type: "object",
|
||
properties: {
|
||
type: {
|
||
type: "string",
|
||
enum: ["CPU", "内存", "硬盘", "主板", "显卡", "机箱"],
|
||
description: "配件类型"
|
||
},
|
||
brand: {
|
||
type: "string",
|
||
description: "品牌名称"
|
||
},
|
||
minPrice: {
|
||
type: "number",
|
||
description: "最低价格"
|
||
},
|
||
maxPrice: {
|
||
type: "number",
|
||
description: "最高价格"
|
||
},
|
||
search: {
|
||
type: "string",
|
||
description: "搜索关键词,会在名称、品牌、型号、描述中搜索"
|
||
},
|
||
page: {
|
||
type: "integer",
|
||
description: "页码,默认为1"
|
||
},
|
||
}
|
||
}
|
||
}, {
|
||
name: "show-components",
|
||
description: "根据提供的配件ID列表,向用户以卡片形式展示一个或多个具体型号的配件。如果你提到了某些特定配件,请用此工具更好地向用户展示。",
|
||
input_schema: {
|
||
type: "object",
|
||
properties: {
|
||
component_ids: {
|
||
type: "array",
|
||
description: "一个包含一个或多个要展示的配件ID的数组。这些ID来自`query-components`工具的查询结果。",
|
||
items: {
|
||
type: "string",
|
||
description: "单个配件的唯一ID"
|
||
}
|
||
}
|
||
},
|
||
required: ["component_ids"]
|
||
}
|
||
}
|
||
];
|
||
|
||
constructor() {
|
||
this.anthropic = new Anthropic({
|
||
apiKey: ANTHROPIC_API_KEY,
|
||
baseURL: ANTHROPIC_BASE_URL,
|
||
});
|
||
}
|
||
|
||
// 查询配件
|
||
private async queryComponents(args: any): Promise<string> {
|
||
try {
|
||
const result = await ComponentService.queryComponents({
|
||
type: args.type,
|
||
brand: args.brand,
|
||
minPrice: args.minPrice,
|
||
maxPrice: args.maxPrice,
|
||
search: args.search,
|
||
page: args.page || 1,
|
||
limit: 5
|
||
})
|
||
|
||
if (result.components.length === 0) {
|
||
return "未找到符合条件的配件"
|
||
}
|
||
|
||
// 返回格式化的结果
|
||
return JSON.stringify(result.components.map(c => ({
|
||
id: c.id,
|
||
name: c.name,
|
||
brand: c.brand,
|
||
model: c.model,
|
||
price: c.price,
|
||
description: c.description,
|
||
stock: c.stock,
|
||
specifications: c.specifications,
|
||
})))
|
||
|
||
} catch (error) {
|
||
console.error('查询配件失败:', error)
|
||
return `查询配件时发生错误: ${error instanceof Error ? error.message : '未知错误'}`
|
||
}
|
||
}
|
||
|
||
|
||
async *processQuery(
|
||
query: string,
|
||
userId: string,
|
||
conversationId?: string
|
||
): AsyncGenerator<string, void, unknown> {
|
||
let messages: MessageParam[];
|
||
let currentConversationId = conversationId;
|
||
|
||
// 如果提供了对话ID,加载现有对话
|
||
if (conversationId) {
|
||
const conversation = await ConversationService.getConversation(conversationId, userId);
|
||
if (conversation) {
|
||
messages = [...conversation.messages];
|
||
// 添加新的用户消息
|
||
messages.push({
|
||
role: "user",
|
||
content: query
|
||
});
|
||
} else {
|
||
throw new Error('对话不存在或无权限访问');
|
||
}
|
||
} else {
|
||
// 创建新对话
|
||
currentConversationId = await ConversationService.createConversation(userId, query);
|
||
|
||
// 发送新对话ID给前端
|
||
yield `conversation_id:${currentConversationId}`;
|
||
|
||
// 获取新创建的对话消息
|
||
const conversation = await ConversationService.getConversation(currentConversationId, userId);
|
||
if (!conversation) {
|
||
throw new Error('创建对话失败');
|
||
}
|
||
messages = conversation.messages;
|
||
}
|
||
|
||
try {
|
||
let maxIterations = 20;
|
||
let currentIteration = 0;
|
||
|
||
while (currentIteration < maxIterations) {
|
||
currentIteration++;
|
||
console.log("Send Req", messages);
|
||
|
||
const response = await this.anthropic.messages.create({
|
||
model: ["claude-sonnet-4-20250514", "claude-3-5-haiku-20241022"][1],
|
||
max_tokens: 2048,
|
||
messages,
|
||
tools: this.tools,
|
||
stream: true,
|
||
});
|
||
|
||
let tempMsg: Message | null = null
|
||
|
||
// 处理流式响应
|
||
for await (const chunk of response) {
|
||
console.log(chunk);
|
||
|
||
switch (chunk.type) {
|
||
case "message_start":
|
||
tempMsg = {
|
||
id: chunk.message.id,
|
||
type: chunk.message.type,
|
||
role: chunk.message.role,
|
||
content: [],
|
||
model: chunk.message.model,
|
||
usage: chunk.message.usage,
|
||
stop_reason: chunk.message.stop_reason,
|
||
stop_sequence: chunk.message.stop_sequence,
|
||
}
|
||
break
|
||
|
||
case "message_delta":
|
||
tempMsg = {
|
||
...tempMsg || {},
|
||
stop_reason: chunk.delta.stop_reason,
|
||
stop_sequence: chunk.delta.stop_sequence,
|
||
// @ts-ignore
|
||
usage: chunk.usage,
|
||
}
|
||
break
|
||
|
||
case "message_stop":
|
||
break
|
||
|
||
case "content_block_start":
|
||
const contentBlock = chunk.content_block;
|
||
switch (contentBlock.type) {
|
||
case "text":
|
||
tempMsg?.content.push({
|
||
type: contentBlock.type,
|
||
text: contentBlock.text,
|
||
citations: contentBlock.citations || [],
|
||
})
|
||
yield contentBlock.text
|
||
break
|
||
case "tool_use":
|
||
tempMsg?.content.push({
|
||
type: contentBlock.type,
|
||
id: contentBlock.id,
|
||
name: contentBlock.name,
|
||
input: "",
|
||
});
|
||
yield `\n\n**使用工具**: ${contentBlock.name}\n\n\`\`\`\n`
|
||
break
|
||
}
|
||
break
|
||
|
||
case "content_block_delta":
|
||
const contentBlockDelta = chunk.delta;
|
||
const targetContent = tempMsg?.content[chunk.index];
|
||
switch (contentBlockDelta.type) {
|
||
case "text_delta":
|
||
if (targetContent && targetContent.type === "text") {
|
||
targetContent.text += contentBlockDelta.text;
|
||
yield contentBlockDelta.text;
|
||
} else console.error("文本块不匹配,无法追加内容", targetContent);
|
||
break
|
||
case "input_json_delta":
|
||
if (targetContent && targetContent.type === "tool_use") {
|
||
targetContent.input += contentBlockDelta.partial_json || "";
|
||
yield contentBlockDelta.partial_json || "";
|
||
} else console.error("工具调用块不匹配,无法追加输入", targetContent);
|
||
break
|
||
}
|
||
break
|
||
|
||
case "content_block_stop":
|
||
let type = tempMsg?.content[chunk.index].type;
|
||
if (type === "tool_use") {
|
||
yield `\n\`\`\``;
|
||
}
|
||
|
||
break
|
||
|
||
}
|
||
}
|
||
|
||
if (!tempMsg) {
|
||
console.error("没有接收到有效的消息内容");
|
||
yield "\n\n❌ 抱歉,处理您的请求时没有返回有效的消息内容。";
|
||
break;
|
||
}
|
||
|
||
messages.push({
|
||
role: tempMsg.role,
|
||
content: tempMsg.content.map(c => {
|
||
if (c.type === "text") {
|
||
return {
|
||
type: c.type,
|
||
text: c.text,
|
||
};
|
||
} else if (c.type === "tool_use") {
|
||
return {
|
||
id: c.id,
|
||
type: c.type,
|
||
name: c.name,
|
||
input: JSON.parse(c.input as string)
|
||
};
|
||
} else return c;
|
||
})
|
||
});
|
||
|
||
if (!tempMsg.content.find(c => c.type === "tool_use")) {
|
||
break
|
||
}
|
||
|
||
let tempUserMsg: MessageParam = {
|
||
role: "user",
|
||
content: []
|
||
}
|
||
|
||
let needRerun = false;
|
||
for (const toolUse of tempMsg.content.filter(c => c.type === "tool_use")) {
|
||
|
||
let toolArgs = JSON.parse(toolUse.input as string || "{}");
|
||
console.log(toolArgs);
|
||
|
||
switch (toolUse.name) {
|
||
case "query-components":
|
||
// @ts-ignore
|
||
tempUserMsg.content.push({
|
||
type: "tool_result",
|
||
tool_use_id: toolUse.id,
|
||
content: await this.queryComponents(toolArgs)
|
||
});
|
||
needRerun = true;
|
||
yield 'next_block'
|
||
break
|
||
case "show-components":
|
||
let components = await ComponentService.getComponentsByIds(toolArgs.component_ids)
|
||
|
||
// @ts-ignore
|
||
tempUserMsg.content.push({
|
||
type: "tool_result",
|
||
tool_use_id: toolUse.id,
|
||
content: `成功向用户展示了 ${JSON.stringify(
|
||
(await ComponentService.getComponentsByIds(toolArgs.component_ids)).map((c, index) => (c && {
|
||
id: c.id,
|
||
name: c.name,
|
||
brand: c.brand,
|
||
model: c.model,
|
||
price: c.price,
|
||
description: c.description,
|
||
stock: c.stock,
|
||
specifications: c.specifications,
|
||
} || { id: toolArgs.component_ids[index].id, name: "未知配件" })
|
||
)
|
||
)}`
|
||
});
|
||
|
||
needRerun = true;
|
||
yield `show_card: ${JSON.stringify(components)}`;
|
||
yield 'next_block'
|
||
break;
|
||
|
||
default:
|
||
console.error(`未知工具调用: ${toolUse.name}`);
|
||
|
||
}
|
||
|
||
// 继续处理下一个工具调用
|
||
}
|
||
|
||
// 将工具调用结果添加到消息列表中
|
||
messages.push(tempUserMsg);
|
||
if (!needRerun) break
|
||
|
||
} if (currentIteration >= maxIterations) {
|
||
yield "\n\n⚠️ 达到最大处理轮次,对话结束。";
|
||
}
|
||
|
||
// 保存更新后的对话到数据库
|
||
if (currentConversationId) {
|
||
await ConversationService.updateConversationMessages(currentConversationId, userId, messages);
|
||
}
|
||
|
||
} catch (error) {
|
||
console.error('处理查询时发生错误:', error);
|
||
yield `\n\n❌ 抱歉,处理您的请求时遇到了问题: ${error instanceof Error ? error.message : '未知错误'}`;
|
||
|
||
// 即使出错也要保存对话
|
||
if (currentConversationId) {
|
||
try {
|
||
await ConversationService.updateConversationMessages(currentConversationId, userId, messages);
|
||
} catch (saveError) {
|
||
console.error('保存对话失败:', saveError);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取用户的对话历史
|
||
*/
|
||
async getUserConversations(userId: string, page = 1, limit = 20) {
|
||
return await ConversationService.getUserConversations(userId, page, limit);
|
||
}
|
||
|
||
/**
|
||
* 获取特定对话的详情
|
||
*/
|
||
async getConversation(conversationId: string, userId: string) {
|
||
return await ConversationService.getConversation(conversationId, userId);
|
||
}
|
||
|
||
/**
|
||
* 删除对话
|
||
*/
|
||
async deleteConversation(conversationId: string, userId: string) {
|
||
return await ConversationService.deleteConversation(conversationId, userId);
|
||
}
|
||
|
||
/**
|
||
* 更新对话标题
|
||
*/
|
||
async updateConversationTitle(conversationId: string, userId: string, title: string) {
|
||
return await ConversationService.updateConversationTitle(conversationId, userId, title);
|
||
}
|
||
|
||
}
|
||
|