476 lines
12 KiB
TypeScript
476 lines
12 KiB
TypeScript
|
|
/**
|
||
|
|
* AI Service
|
||
|
|
* 支持 6 个主流 AI 服务商进行文档智能分析
|
||
|
|
* - GLM (智谱 AI)
|
||
|
|
* - MiniMax
|
||
|
|
* - DeepSeek
|
||
|
|
* - Kimi (月之暗面)
|
||
|
|
* - OpenAI
|
||
|
|
* - Anthropic (Claude)
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { prisma } from '../lib/prisma';
|
||
|
|
|
||
|
|
// AI Provider 类型
|
||
|
|
export type AIProviderType = 'glm' | 'minimax' | 'deepseek' | 'kimi' | 'openai' | 'anthropic';
|
||
|
|
|
||
|
|
// AI 分析结果
|
||
|
|
export interface AIAnalysisResult {
|
||
|
|
suggested_tags: string[];
|
||
|
|
suggested_category?: string;
|
||
|
|
summary?: string;
|
||
|
|
raw_response: string;
|
||
|
|
provider: AIProviderType;
|
||
|
|
model: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
// AI Provider 配置
|
||
|
|
export interface AIProviderConfig {
|
||
|
|
apiKey: string;
|
||
|
|
apiUrl: string;
|
||
|
|
model: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Provider 配置映射
|
||
|
|
export interface AIConfig {
|
||
|
|
defaultProvider: AIProviderType;
|
||
|
|
glm: AIProviderConfig;
|
||
|
|
minimax: AIProviderConfig;
|
||
|
|
deepseek: AIProviderConfig;
|
||
|
|
kimi: AIProviderConfig;
|
||
|
|
openai: AIProviderConfig;
|
||
|
|
anthropic: AIProviderConfig;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 分析选项
|
||
|
|
export interface AnalyzeOptions {
|
||
|
|
provider?: AIProviderType;
|
||
|
|
config?: AIConfig;
|
||
|
|
generateSummary?: boolean;
|
||
|
|
maxTags?: number;
|
||
|
|
}
|
||
|
|
|
||
|
|
// API 响应接口
|
||
|
|
interface ChatMessage {
|
||
|
|
role: 'system' | 'user' | 'assistant';
|
||
|
|
content: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
interface ChatCompletionResponse {
|
||
|
|
choices: Array<{
|
||
|
|
message: {
|
||
|
|
content: string;
|
||
|
|
};
|
||
|
|
}>;
|
||
|
|
usage?: {
|
||
|
|
prompt_tokens: number;
|
||
|
|
completion_tokens: number;
|
||
|
|
total_tokens: number;
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
interface AnthropicResponse {
|
||
|
|
content: Array<{
|
||
|
|
type: string;
|
||
|
|
text: string;
|
||
|
|
}>;
|
||
|
|
usage?: {
|
||
|
|
input_tokens: number;
|
||
|
|
output_tokens: number;
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* AI 服务类
|
||
|
|
*/
|
||
|
|
export class AIService {
|
||
|
|
/**
|
||
|
|
* 从环境变量或用户配置获取 AI 配置
|
||
|
|
*/
|
||
|
|
private static getAIConfig(_userId: string): AIConfig {
|
||
|
|
return {
|
||
|
|
defaultProvider: 'glm',
|
||
|
|
glm: {
|
||
|
|
apiKey: process.env.GLM_API_KEY || '',
|
||
|
|
apiUrl: process.env.GLM_API_URL || 'https://open.bigmodel.cn/api/paas/v4/chat/completions',
|
||
|
|
model: process.env.GLM_MODEL || 'glm-4-flash',
|
||
|
|
},
|
||
|
|
minimax: {
|
||
|
|
apiKey: process.env.MINIMAX_API_KEY || '',
|
||
|
|
apiUrl: process.env.MINIMAX_API_URL || 'https://api.minimax.chat/v1/chat/completions',
|
||
|
|
model: process.env.MINIMAX_MODEL || 'abab6.5s-chat',
|
||
|
|
},
|
||
|
|
deepseek: {
|
||
|
|
apiKey: process.env.DEEPSEEK_API_KEY || '',
|
||
|
|
apiUrl: process.env.DEEPSEEK_API_URL || 'https://api.deepseek.com/v1/chat/completions',
|
||
|
|
model: process.env.DEEPSEEK_MODEL || 'deepseek-chat',
|
||
|
|
},
|
||
|
|
kimi: {
|
||
|
|
apiKey: process.env.KIMI_API_KEY || '',
|
||
|
|
apiUrl: process.env.KIMI_API_URL || 'https://api.moonshot.cn/v1/chat/completions',
|
||
|
|
model: process.env.KIMI_MODEL || 'moonshot-v1-8k',
|
||
|
|
},
|
||
|
|
openai: {
|
||
|
|
apiKey: process.env.OPENAI_API_KEY || '',
|
||
|
|
apiUrl: process.env.OPENAI_API_URL || 'https://api.openai.com/v1/chat/completions',
|
||
|
|
model: process.env.OPENAI_MODEL || 'gpt-4o-mini',
|
||
|
|
},
|
||
|
|
anthropic: {
|
||
|
|
apiKey: process.env.ANTHROPIC_API_KEY || '',
|
||
|
|
apiUrl: process.env.ANTHROPIC_API_URL || 'https://api.anthropic.com/v1/messages',
|
||
|
|
model: process.env.ANTHROPIC_MODEL || 'claude-3-5-sonnet-20241022',
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 调用 OpenAI 兼容的 API (GLM, MiniMax, DeepSeek, Kimi, OpenAI)
|
||
|
|
*/
|
||
|
|
private static async callOpenAICompatibleAPI(
|
||
|
|
config: AIProviderConfig,
|
||
|
|
messages: ChatMessage[]
|
||
|
|
): Promise<string> {
|
||
|
|
const response = await fetch(config.apiUrl, {
|
||
|
|
method: 'POST',
|
||
|
|
headers: {
|
||
|
|
'Content-Type': 'application/json',
|
||
|
|
'Authorization': `Bearer ${config.apiKey}`,
|
||
|
|
},
|
||
|
|
body: JSON.stringify({
|
||
|
|
model: config.model,
|
||
|
|
messages,
|
||
|
|
temperature: 0.3,
|
||
|
|
max_tokens: 1000,
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!response.ok) {
|
||
|
|
const errorText = await response.text();
|
||
|
|
throw new Error(`API 请求失败 (${response.status}): ${errorText}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const data = await response.json() as ChatCompletionResponse;
|
||
|
|
return data.choices[0]?.message?.content || '';
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 调用 Anthropic Claude API
|
||
|
|
*/
|
||
|
|
private static async callAnthropicAPI(
|
||
|
|
config: AIProviderConfig,
|
||
|
|
messages: ChatMessage[]
|
||
|
|
): Promise<string> {
|
||
|
|
// Anthropic API 需要提取 system 消息
|
||
|
|
const systemMessage = messages.find(m => m.role === 'system')?.content || '';
|
||
|
|
const userMessages = messages.filter(m => m.role !== 'system');
|
||
|
|
|
||
|
|
const response = await fetch(config.apiUrl, {
|
||
|
|
method: 'POST',
|
||
|
|
headers: {
|
||
|
|
'Content-Type': 'application/json',
|
||
|
|
'x-api-key': config.apiKey,
|
||
|
|
'anthropic-version': '2023-06-01',
|
||
|
|
},
|
||
|
|
body: JSON.stringify({
|
||
|
|
model: config.model,
|
||
|
|
system: systemMessage,
|
||
|
|
messages: userMessages,
|
||
|
|
max_tokens: 1000,
|
||
|
|
}),
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!response.ok) {
|
||
|
|
const errorText = await response.text();
|
||
|
|
throw new Error(`Anthropic API 请求失败 (${response.status}): ${errorText}`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const data = await response.json() as AnthropicResponse;
|
||
|
|
return data.content[0]?.text || '';
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 调用 AI Provider 分析文档
|
||
|
|
*/
|
||
|
|
private static async callAIProvider(
|
||
|
|
provider: AIProviderType,
|
||
|
|
config: AIConfig,
|
||
|
|
content: string,
|
||
|
|
prompt: string
|
||
|
|
): Promise<string> {
|
||
|
|
const providerConfig = config[provider];
|
||
|
|
|
||
|
|
if (!providerConfig.apiKey) {
|
||
|
|
throw new Error(`${provider} API Key 未配置`);
|
||
|
|
}
|
||
|
|
|
||
|
|
const messages: ChatMessage[] = [
|
||
|
|
{
|
||
|
|
role: 'system',
|
||
|
|
content: `你是一个智能文档分析助手。${prompt}`,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
role: 'user',
|
||
|
|
content: `请分析以下文档内容:\n\n${content}`,
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
// Anthropic 使用不同的 API 格式
|
||
|
|
if (provider === 'anthropic') {
|
||
|
|
return this.callAnthropicAPI(providerConfig, messages);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 其他 Provider 使用 OpenAI 兼容格式
|
||
|
|
return this.callOpenAICompatibleAPI(providerConfig, messages);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 解析 AI 响应,提取结构化数据
|
||
|
|
*/
|
||
|
|
private static parseAIResponse(response: string): {
|
||
|
|
tags: string[];
|
||
|
|
category?: string;
|
||
|
|
summary?: string;
|
||
|
|
} {
|
||
|
|
const result = {
|
||
|
|
tags: [] as string[],
|
||
|
|
category: undefined as string | undefined,
|
||
|
|
summary: undefined as string | undefined,
|
||
|
|
};
|
||
|
|
|
||
|
|
// 尝试解析 JSON 格式响应
|
||
|
|
try {
|
||
|
|
// 查找 JSON 块
|
||
|
|
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
||
|
|
if (jsonMatch) {
|
||
|
|
const parsed = JSON.parse(jsonMatch[0]);
|
||
|
|
result.tags = parsed.tags || [];
|
||
|
|
result.category = parsed.category;
|
||
|
|
result.summary = parsed.summary;
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
} catch (e) {
|
||
|
|
// JSON 解析失败,使用文本解析
|
||
|
|
}
|
||
|
|
|
||
|
|
// 文本解析:按行查找标签、分类和摘要
|
||
|
|
const lines = response.split('\n');
|
||
|
|
for (const line of lines) {
|
||
|
|
const lowerLine = line.toLowerCase().trim();
|
||
|
|
|
||
|
|
if (lowerLine.startsWith('标签:') || lowerLine.startsWith('tags:')) {
|
||
|
|
const tags = line.substring(line.indexOf(':') + 1).trim();
|
||
|
|
result.tags = tags.split(/[,,、]/).map(t => t.trim()).filter(t => t);
|
||
|
|
} else if (lowerLine.startsWith('分类:') || lowerLine.startsWith('category:')) {
|
||
|
|
result.category = line.substring(line.indexOf(':') + 1).trim();
|
||
|
|
} else if (lowerLine.startsWith('摘要:') || lowerLine.startsWith('summary:')) {
|
||
|
|
result.summary = line.substring(line.indexOf(':') + 1).trim();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 如果没有找到标签,尝试从响应中提取关键词
|
||
|
|
if (result.tags.length === 0) {
|
||
|
|
const words = response.match(/[\u4e00-\u9fa5]{2,4}/g) || [];
|
||
|
|
result.tags = [...new Set(words)].slice(0, 5);
|
||
|
|
}
|
||
|
|
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 分析文档内容,生成智能标签和分类
|
||
|
|
*/
|
||
|
|
static async analyzeDocument(
|
||
|
|
documentId: string,
|
||
|
|
userId: string,
|
||
|
|
options: AnalyzeOptions = {}
|
||
|
|
): Promise<AIAnalysisResult> {
|
||
|
|
// 获取文档
|
||
|
|
const document = await prisma.document.findFirst({
|
||
|
|
where: {
|
||
|
|
id: documentId,
|
||
|
|
user_id: userId,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!document) {
|
||
|
|
throw new Error('文档不存在或无权访问');
|
||
|
|
}
|
||
|
|
|
||
|
|
// 获取 AI 配置
|
||
|
|
const config = options.config || this.getAIConfig(userId);
|
||
|
|
const provider = options.provider || config.defaultProvider;
|
||
|
|
|
||
|
|
// 检查 API Key
|
||
|
|
const providerConfig = config[provider];
|
||
|
|
if (!providerConfig.apiKey) {
|
||
|
|
throw new Error(`${provider} API Key 未配置,请在设置页面配置`);
|
||
|
|
}
|
||
|
|
|
||
|
|
// 构建分析提示词
|
||
|
|
const prompt = `请分析文档内容,返回以下信息(JSON 格式):
|
||
|
|
{
|
||
|
|
"tags": ["标签1", "标签2", "标签3"], // 3-5个关键词标签
|
||
|
|
"category": "建议的分类名称", // 可选
|
||
|
|
"summary": "一句话摘要" // 可选
|
||
|
|
}
|
||
|
|
|
||
|
|
要求:
|
||
|
|
1. 标签应该能体现文档的核心内容
|
||
|
|
2. 分类应该简洁明了(如:工作、学习、生活、技术等)
|
||
|
|
3. 摘要应该简洁概括文档要点
|
||
|
|
4. 直接返回 JSON,不要有其他内容`;
|
||
|
|
|
||
|
|
// 调用 AI API
|
||
|
|
try {
|
||
|
|
const rawResponse = await this.callAIProvider(
|
||
|
|
provider,
|
||
|
|
config,
|
||
|
|
document.content,
|
||
|
|
prompt
|
||
|
|
);
|
||
|
|
|
||
|
|
// 解析响应
|
||
|
|
const parsed = this.parseAIResponse(rawResponse);
|
||
|
|
|
||
|
|
// 限制标签数量
|
||
|
|
const maxTags = options.maxTags || 5;
|
||
|
|
const suggestedTags = parsed.tags.slice(0, maxTags);
|
||
|
|
|
||
|
|
// 保存分析结果到数据库
|
||
|
|
await prisma.aIAnalysis.upsert({
|
||
|
|
where: { document_id: documentId },
|
||
|
|
create: {
|
||
|
|
document_id: documentId,
|
||
|
|
provider,
|
||
|
|
model: providerConfig.model,
|
||
|
|
suggested_tags: JSON.stringify(suggestedTags),
|
||
|
|
suggested_category: parsed.category,
|
||
|
|
summary: parsed.summary,
|
||
|
|
raw_response: rawResponse,
|
||
|
|
},
|
||
|
|
update: {
|
||
|
|
provider,
|
||
|
|
model: providerConfig.model,
|
||
|
|
suggested_tags: JSON.stringify(suggestedTags),
|
||
|
|
suggested_category: parsed.category,
|
||
|
|
summary: parsed.summary,
|
||
|
|
raw_response: rawResponse,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
return {
|
||
|
|
suggested_tags: suggestedTags,
|
||
|
|
suggested_category: parsed.category,
|
||
|
|
summary: parsed.summary,
|
||
|
|
raw_response: rawResponse,
|
||
|
|
provider,
|
||
|
|
model: providerConfig.model,
|
||
|
|
};
|
||
|
|
} catch (error) {
|
||
|
|
console.error('[AI] 分析失败:', error);
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 获取文档的 AI 分析结果
|
||
|
|
*/
|
||
|
|
static async getAnalysis(documentId: string, userId: string) {
|
||
|
|
// 验证文档所有权
|
||
|
|
const document = await prisma.document.findFirst({
|
||
|
|
where: {
|
||
|
|
id: documentId,
|
||
|
|
user_id: userId,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!document) {
|
||
|
|
throw new Error('文档不存在或无权访问');
|
||
|
|
}
|
||
|
|
|
||
|
|
const analysis = await prisma.aIAnalysis.findUnique({
|
||
|
|
where: { document_id: documentId },
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!analysis) {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
...analysis,
|
||
|
|
suggested_tags: JSON.parse(analysis.suggested_tags),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 删除文档的 AI 分析结果
|
||
|
|
*/
|
||
|
|
static async deleteAnalysis(documentId: string, userId: string) {
|
||
|
|
// 验证文档所有权
|
||
|
|
const document = await prisma.document.findFirst({
|
||
|
|
where: {
|
||
|
|
id: documentId,
|
||
|
|
user_id: userId,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
if (!document) {
|
||
|
|
throw new Error('文档不存在或无权访问');
|
||
|
|
}
|
||
|
|
|
||
|
|
await prisma.aIAnalysis.delete({
|
||
|
|
where: { document_id: documentId },
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 测试 AI Provider 连接
|
||
|
|
*/
|
||
|
|
static async testProvider(
|
||
|
|
provider: AIProviderType,
|
||
|
|
config?: Partial<AIConfig>
|
||
|
|
): Promise<{
|
||
|
|
success: boolean;
|
||
|
|
message: string;
|
||
|
|
error?: string;
|
||
|
|
}> {
|
||
|
|
try {
|
||
|
|
const fullConfig = config ? { ...this.getAIConfig(''), ...config } : this.getAIConfig('');
|
||
|
|
const providerConfig = fullConfig[provider];
|
||
|
|
|
||
|
|
if (!providerConfig.apiKey) {
|
||
|
|
return {
|
||
|
|
success: false,
|
||
|
|
message: 'API Key 未配置',
|
||
|
|
error: `${provider} API Key 未配置`,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
// 发送简单测试请求
|
||
|
|
const messages: ChatMessage[] = [
|
||
|
|
{
|
||
|
|
role: 'user',
|
||
|
|
content: 'Hi',
|
||
|
|
},
|
||
|
|
];
|
||
|
|
|
||
|
|
if (provider === 'anthropic') {
|
||
|
|
await this.callAnthropicAPI(providerConfig, messages);
|
||
|
|
} else {
|
||
|
|
await this.callOpenAICompatibleAPI(providerConfig, messages);
|
||
|
|
}
|
||
|
|
|
||
|
|
return {
|
||
|
|
success: true,
|
||
|
|
message: `${provider} 连接成功`,
|
||
|
|
};
|
||
|
|
} catch (error) {
|
||
|
|
return {
|
||
|
|
success: false,
|
||
|
|
message: '连接失败',
|
||
|
|
error: error instanceof Error ? error.message : String(error),
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|