402 行
12 KiB
TypeScript
402 行
12 KiB
TypeScript
import { Provide } from '@midwayjs/core';
|
||
import { BaseService, CoolCommException } from '@cool-midway/core';
|
||
import axios, { AxiosInstance } from 'axios';
|
||
import {
|
||
DeepSeekChatCompletionRequest,
|
||
DeepSeekChatCompletionResponse,
|
||
DeepSeekConfig,
|
||
DeepSeekModel,
|
||
DeepSeekTestConnectionResult,
|
||
} from '../../../comm/types/omni';
|
||
|
||
/**
|
||
* DeepSeek 官方大模型适配服务
|
||
* 支持 deepseek-chat (V3) 与 deepseek-reasoner (R1)
|
||
*/
|
||
@Provide()
|
||
export class DeepSeekService extends BaseService {
|
||
private config: DeepSeekConfig;
|
||
private httpClient: AxiosInstance;
|
||
|
||
/** 多租户私有 BYOK 配置映射表 */
|
||
private tenantConfigs: Map<string | number, DeepSeekConfig> = new Map();
|
||
|
||
/** 租户 Token 累计消耗账本 */
|
||
private tenantUsage: Map<
|
||
string | number,
|
||
{ totalTokens: number; promptTokens: number; completionTokens: number; callCount: number }
|
||
> = new Map();
|
||
|
||
/** 租户 Token 配额上限表 */
|
||
private tenantQuotas: Map<string | number, number> = new Map();
|
||
|
||
constructor() {
|
||
super();
|
||
const envKey = process.env.DEEPSEEK_API_KEY?.trim() || undefined;
|
||
const envBaseUrl = process.env.DEEPSEEK_BASE_URL?.trim() || 'https://api.deepseek.com';
|
||
const envModel = (process.env.DEEPSEEK_MODEL?.trim() as DeepSeekModel) || 'deepseek-chat';
|
||
|
||
this.config = {
|
||
apiKey: envKey,
|
||
baseUrl: envBaseUrl,
|
||
model: envModel,
|
||
temperature: 0.7,
|
||
maxTokens: 2048,
|
||
timeoutMs: 30000,
|
||
enabled: Boolean(envKey),
|
||
};
|
||
|
||
this.httpClient = axios.create({
|
||
timeout: this.config.timeoutMs,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 获取当前有效配置(脱敏 API Key)
|
||
*/
|
||
getConfig(): DeepSeekConfig {
|
||
return { ...this.config };
|
||
}
|
||
|
||
/**
|
||
* 判断指定租户是否已配置专属私有 Key
|
||
*/
|
||
hasTenantConfig(tenantId: string | number): boolean {
|
||
return this.tenantConfigs.has(tenantId);
|
||
}
|
||
|
||
/**
|
||
* 获取指定租户有效配置(未单独配置则优雅回退至平台公共默认配置)
|
||
*/
|
||
getTenantConfig(tenantId?: string | number): DeepSeekConfig {
|
||
if (tenantId !== undefined && tenantId !== null && this.tenantConfigs.has(tenantId)) {
|
||
return { ...this.tenantConfigs.get(tenantId)! };
|
||
}
|
||
return this.getConfig();
|
||
}
|
||
|
||
/**
|
||
* 为指定企业租户更新专属定制配置 (BYOK)
|
||
*/
|
||
updateTenantConfig(tenantId: string | number, partial: Partial<DeepSeekConfig>): void {
|
||
const current = this.getTenantConfig(tenantId);
|
||
const updated: DeepSeekConfig = {
|
||
...current,
|
||
...partial,
|
||
enabled: partial.apiKey ? Boolean(partial.apiKey.trim()) : current.enabled,
|
||
};
|
||
this.tenantConfigs.set(tenantId, updated);
|
||
}
|
||
|
||
/**
|
||
* 记录指定租户的 Token 消耗
|
||
*/
|
||
recordTokenUsage(
|
||
tenantId: string | number,
|
||
tokens:
|
||
| number
|
||
| { promptTokens?: number; completionTokens?: number; totalTokens?: number }
|
||
): void {
|
||
const current = this.tenantUsage.get(tenantId) || {
|
||
totalTokens: 0,
|
||
promptTokens: 0,
|
||
completionTokens: 0,
|
||
callCount: 0,
|
||
};
|
||
|
||
let prompt = 0;
|
||
let completion = 0;
|
||
let total = 0;
|
||
|
||
if (typeof tokens === 'number') {
|
||
total = Number(tokens) || 0;
|
||
} else if (tokens && typeof tokens === 'object') {
|
||
prompt = Number(tokens.promptTokens) || 0;
|
||
completion = Number(tokens.completionTokens) || 0;
|
||
total = Number(tokens.totalTokens) || prompt + completion;
|
||
}
|
||
|
||
this.tenantUsage.set(tenantId, {
|
||
totalTokens: current.totalTokens + total,
|
||
promptTokens: current.promptTokens + prompt,
|
||
completionTokens: current.completionTokens + completion,
|
||
callCount: current.callCount + 1,
|
||
});
|
||
}
|
||
|
||
/**
|
||
* 获取指定租户的 Token 用量统计
|
||
*/
|
||
getTenantTokenUsage(tenantId: string | number): {
|
||
totalTokens: number;
|
||
promptTokens: number;
|
||
completionTokens: number;
|
||
callCount: number;
|
||
} {
|
||
return this.tenantUsage.get(tenantId) || {
|
||
totalTokens: 0,
|
||
promptTokens: 0,
|
||
completionTokens: 0,
|
||
callCount: 0,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* 设置指定租户的 Token 最大额度上限
|
||
*/
|
||
setTenantTokenQuota(tenantId: string | number, maxTotalTokens: number): void {
|
||
this.tenantQuotas.set(tenantId, Math.max(0, Number(maxTotalTokens) || 0));
|
||
}
|
||
|
||
/**
|
||
* 获取指定租户的 Token 最大额度上限
|
||
*/
|
||
getTenantTokenQuota(tenantId: string | number): number {
|
||
return this.tenantQuotas.get(tenantId) || 0;
|
||
}
|
||
|
||
/**
|
||
* 断言当前租户 Token 配额充足,超限则抛出 429 阻断
|
||
*/
|
||
assertTenantTokenQuota(tenantId: string | number, estimatedTokens = 1): void {
|
||
const quota = this.tenantQuotas.get(tenantId);
|
||
if (quota === undefined) {
|
||
return;
|
||
}
|
||
const current = this.getTenantTokenUsage(tenantId).totalTokens;
|
||
if (current + estimatedTokens > quota) {
|
||
throw new CoolCommException(
|
||
`当前租户 AI Token 配额已耗尽 (${current}/${quota}),请升级套餐或补充额度`,
|
||
429
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 动态更新配置
|
||
*/
|
||
updateConfig(partial: Partial<DeepSeekConfig>): void {
|
||
this.config = {
|
||
...this.config,
|
||
...partial,
|
||
enabled: partial.apiKey ? Boolean(partial.apiKey.trim()) : this.config.enabled,
|
||
};
|
||
if (this.config.timeoutMs) {
|
||
this.httpClient.defaults.timeout = this.config.timeoutMs;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 检查是否已配置有效 API Key(支持按租户检查或默认平台池检查)
|
||
*/
|
||
isConfigured(tenantId?: number | string): boolean {
|
||
const cfg = this.getTenantConfig(tenantId);
|
||
return Boolean(cfg.apiKey && cfg.apiKey.trim().length > 0);
|
||
}
|
||
|
||
/**
|
||
* 组装名片 AI 顾问 System 提示词
|
||
*/
|
||
buildSystemPrompt(
|
||
card?: { name?: string; company?: string; title?: string },
|
||
concierge?: { personaType?: string; toneStyle?: string; promptTemplate?: string },
|
||
knowledgeChunks?: Array<{ index?: number; content: string }>
|
||
): string {
|
||
const parts: string[] = [];
|
||
|
||
parts.push('你是微官网的在线智能商务顾问。');
|
||
if (card?.name) parts.push(`名片所有者姓名: ${card.name}`);
|
||
if (card?.company) parts.push(`所属企业: ${card.company}`);
|
||
if (card?.title) parts.push(`头衔职位: ${card.title}`);
|
||
|
||
if (concierge?.promptTemplate) {
|
||
parts.push(`人设与使命:\n${concierge.promptTemplate}`);
|
||
} else {
|
||
parts.push('你的职责是以专业、礼貌、高效的口吻向访客介绍业务与产品,并在适当时机引导有意向的客户留下联系方式。');
|
||
}
|
||
|
||
if (Array.isArray(knowledgeChunks) && knowledgeChunks.length > 0) {
|
||
parts.push('参考企业知识库信息 (优先基于以下内容作答):');
|
||
knowledgeChunks.forEach((c, idx) => {
|
||
parts.push(`[${idx + 1}] ${c.content}`);
|
||
});
|
||
}
|
||
|
||
parts.push('请直接给出自然得体的中文或对应语言回答,避免套话。');
|
||
return parts.join('\n');
|
||
}
|
||
|
||
/**
|
||
* 组装商务文案本地化润色提示词
|
||
*/
|
||
buildLocalizePrompt(
|
||
text: string,
|
||
targetLocale: string,
|
||
tone: string,
|
||
glossaryTerms?: string[]
|
||
): string {
|
||
const lines = [
|
||
`请将以下商务文本改写/润色为符合目标语言(${targetLocale})与目标语调(${tone})的地道国际商业文案。`,
|
||
'要求:语义准确地道,符合国际商业习惯,禁止机械直译。',
|
||
];
|
||
|
||
if (Array.isArray(glossaryTerms) && glossaryTerms.length > 0) {
|
||
lines.push('行业专有名词强制对齐规则 (严格遵照):');
|
||
glossaryTerms.forEach((g) => lines.push(`- ${g}`));
|
||
}
|
||
|
||
lines.push(`\n原文:\n${text}`);
|
||
return lines.join('\n');
|
||
}
|
||
|
||
/**
|
||
* 组装销售 Copilot 破冰首语提示词
|
||
*/
|
||
buildCopilotPrompt(
|
||
lead: { name?: string; company?: string; demand?: string; phone?: string },
|
||
tracks?: Array<{ module?: string; duration?: number }>
|
||
): string {
|
||
const lines = [
|
||
'你是一名资深国际商务拓展顾问。请根据以下潜在客户画像与其在名片上的浏览行为轨迹,生成一段极具针对性的 WhatsApp/Email 商务破冰首语建议。',
|
||
`潜客姓名: ${lead?.name || '客商'}`,
|
||
`所属公司: ${lead?.company || '目标企业'}`,
|
||
`核心诉求: ${lead?.demand || '商务洽谈'}`,
|
||
];
|
||
|
||
if (Array.isArray(tracks) && tracks.length > 0) {
|
||
lines.push('访客轨迹分析:');
|
||
tracks.forEach((t) => {
|
||
lines.push(`- 模块 [${t.module || 'portal'}], 停留时长: ${t.duration || 0}秒`);
|
||
});
|
||
}
|
||
|
||
lines.push('请生成:1. 客户核心意向诊断要点;2. 30秒直击痛点的破冰首语推荐。');
|
||
return lines.join('\n');
|
||
}
|
||
|
||
/**
|
||
* 对话补全调用 (支持多租户 BYOK、Token 计量与优雅降级)
|
||
*/
|
||
async chatCompletion(
|
||
req: DeepSeekChatCompletionRequest,
|
||
fallbackReply?: string,
|
||
tenantId?: number | string
|
||
): Promise<string> {
|
||
const cfg = this.getTenantConfig(tenantId);
|
||
const apiKey = cfg.apiKey?.trim();
|
||
|
||
if (!apiKey) {
|
||
if (fallbackReply !== undefined) {
|
||
return fallbackReply;
|
||
}
|
||
throw new CoolCommException('DeepSeek API Key 未配置');
|
||
}
|
||
|
||
// 租户 Token 配额校验 (预估输入字符数 / 2)
|
||
const estimatedTokens = Math.ceil(
|
||
(req.messages || []).reduce((acc, m) => acc + (m.content || '').length, 0) / 2
|
||
);
|
||
this.assertTenantTokenQuota(tenantId, estimatedTokens);
|
||
|
||
const endpoint = `${cfg.baseUrl || 'https://api.deepseek.com'}/chat/completions`;
|
||
const payload = {
|
||
model: req.model || cfg.model || 'deepseek-chat',
|
||
messages: req.messages,
|
||
temperature: req.temperature ?? cfg.temperature ?? 0.7,
|
||
max_tokens: req.maxTokens ?? cfg.maxTokens ?? 2048,
|
||
stream: false,
|
||
};
|
||
|
||
try {
|
||
const response = await this.httpClient.post<DeepSeekChatCompletionResponse>(
|
||
endpoint,
|
||
payload,
|
||
{
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: `Bearer ${apiKey}`,
|
||
},
|
||
}
|
||
);
|
||
|
||
// 记录实际 Token 消耗到租户账本
|
||
if (response.data?.usage?.total_tokens) {
|
||
this.recordTokenUsage(tenantId, response.data.usage.total_tokens);
|
||
}
|
||
|
||
const content = response.data?.choices?.[0]?.message?.content;
|
||
if (content) {
|
||
return content.trim();
|
||
}
|
||
|
||
if (fallbackReply !== undefined) {
|
||
return fallbackReply;
|
||
}
|
||
return 'AI 思考中,暂未生成有效回复。';
|
||
} catch (err: any) {
|
||
if (fallbackReply !== undefined) {
|
||
return fallbackReply;
|
||
}
|
||
const errMsg =
|
||
err?.response?.data?.error?.message || err?.message || 'DeepSeek API 调用失败';
|
||
throw new CoolCommException(`DeepSeek 服务异常: ${errMsg}`);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 测试与 DeepSeek 官方端点连通性
|
||
*/
|
||
async testConnection(
|
||
testApiKey?: string,
|
||
tenantId?: number | string
|
||
): Promise<DeepSeekTestConnectionResult> {
|
||
const cfg = this.getTenantConfig(tenantId);
|
||
const key = (testApiKey || cfg.apiKey || '').trim();
|
||
if (!key) {
|
||
return {
|
||
success: false,
|
||
message: 'API Key 未提供,无法测试连通性',
|
||
};
|
||
}
|
||
|
||
const startTime = Date.now();
|
||
const endpoint = `${cfg.baseUrl || 'https://api.deepseek.com'}/chat/completions`;
|
||
const model = cfg.model || 'deepseek-chat';
|
||
|
||
try {
|
||
await this.httpClient.post(
|
||
endpoint,
|
||
{
|
||
model,
|
||
messages: [{ role: 'user', content: 'hi' }],
|
||
max_tokens: 5,
|
||
},
|
||
{
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: `Bearer ${key}`,
|
||
},
|
||
timeout: 10000, // 测试连通性 10 秒超时
|
||
}
|
||
);
|
||
|
||
const latencyMs = Date.now() - startTime;
|
||
return {
|
||
success: true,
|
||
latencyMs,
|
||
model,
|
||
message: 'DeepSeek API 连通成功',
|
||
};
|
||
} catch (err: any) {
|
||
const errMsg =
|
||
err?.response?.data?.error?.message || err?.message || '网络连接超时或凭证失效';
|
||
return {
|
||
success: false,
|
||
latencyMs: Date.now() - startTime,
|
||
model,
|
||
message: `连通失败: ${errMsg}`,
|
||
};
|
||
}
|
||
}
|
||
}
|