Files
ai-learning-platform/backend/src/modules/ai/ai-gateway.service.ts
T
TradeMate Dev cd0ef01c7c feat: 沙盒模型合规优化 — 移除境外模型引用,新增 GET /sandbox/models 接口
- MODEL_CATALOG 仅保留国内已备案模型(DeepSeek/SenseNova)
- chat()/chatStream() modelMap 仅路由到国内 provider
- provider 命名明确化:"OpenAI 兼容接口" → "硅基流动 / DeepSeek"
- 新增 getSandboxModels() 动态返回可用模型列表
- sandbox 控制器拆分 auth 级别,GET /sandbox/models 无需认证
- 新增 getAvailableModels() 供前端调用

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-23 21:23:21 +08:00

296 lines
10 KiB
TypeScript
Executable File

import { Injectable, Logger } from '@nestjs/common';
interface ChatMessage {
role: 'user' | 'assistant' | 'system';
content: string | { type: 'text' | 'image_url'; text?: string; image_url?: { url: string } }[];
}
export interface ChatOptions {
temperature?: number;
top_p?: number;
max_tokens?: number;
}
export interface ModelInfo {
id: string;
provider: string;
capabilities: string[];
contextWindow: number;
}
interface AIProvider {
name: string;
chat(messages: ChatMessage[], options?: ChatOptions): Promise<string>;
chatStream?(messages: ChatMessage[], options?: ChatOptions): AsyncIterable<string>;
}
// 仅收录沙箱实际可用的国内已备案大模型
const MODEL_CATALOG: Record<string, ModelInfo> = {
'deepseek-v4-flash': { id: 'deepseek-v4-flash', provider: '硅基流动/DeepSeek', capabilities: ['chat', 'code'], contextWindow: 32768 },
'sensenova-6.7-flash-lite': { id: 'sensenova-6.7-flash-lite', provider: '商汤科技', capabilities: ['chat', 'code'], contextWindow: 32768 },
};
/** 沙箱可选模型列表(前端展示用),modelId 必须与 MODEL_CATALOG 中的 id 一致 */
export const SANDBOX_MODELS = [
{ id: 'deepseek-v4-flash', label: 'DeepSeek V4 Flash', provider: '硅基流动', desc: '高速推理,代码生成能力强,价格极低', available: false },
{ id: 'sensenova-6.7-flash-lite', label: 'SenseNova 6.7 Flash Lite', provider: '商汤科技', desc: '轻量快速,日常对话与文本处理', available: false },
];
@Injectable()
export class AIGatewayService {
private readonly logger = new Logger(AIGatewayService.name);
private providers: Map<string, AIProvider> = new Map();
private usageStats: Record<string, { total: number; success: number; failed: number }> = {};
constructor() {
this.registerProviders();
}
private registerProviders() {
// 国内已备案大模型 — 通过硅基流动 / DeepSeek 直连等国内渠道
if (process.env.OPENAI_API_KEY) {
let apiUrl = process.env.OPENAI_API_URL || 'https://api.openai.com/v1/chat/completions';
if (!apiUrl.endsWith('/chat/completions')) {
apiUrl = apiUrl.replace(/\/+$/, '') + '/chat/completions';
}
const defaultModel = process.env.OPENAI_MODEL || 'deepseek/deepseek-v4-flash';
this.providers.set('deepseek-v4-flash', new OpenAICompatibleProvider(
process.env.OPENAI_API_KEY!,
apiUrl,
defaultModel,
'硅基流动 / DeepSeek',
));
this.logger.log(`DeepSeek(通过国内渠道)已注册: ${defaultModel}`);
this.markAvailable('deepseek-v4-flash');
}
if (process.env.SENSENOVA_API_KEY) {
let apiUrl = process.env.SENSENOVA_API_URL || 'https://token.sensenova.cn/v1';
if (!apiUrl.endsWith('/chat/completions')) {
apiUrl = apiUrl.replace(/\/+$/, '') + '/chat/completions';
}
const models = ['deepseek-v4-flash', 'sensenova-6.7-flash-lite'];
for (const modelName of models) {
this.providers.set(modelName, new OpenAICompatibleProvider(
process.env.SENSENOVA_API_KEY!,
apiUrl,
modelName,
'商汤科技',
));
this.markAvailable(modelName);
}
this.logger.log(`商汤科技已注册: ${models.join(', ')}`);
}
}
/** 标记 SANDBOX_MODELS 中的对应模型为可用 */
private markAvailable(modelId: string) {
for (const m of SANDBOX_MODELS) {
if (m.id === modelId) m.available = true;
}
}
async chat(model: string, messages: ChatMessage[], options?: ChatOptions): Promise<string> {
// 仅路由到国内已备案模型的 provider
const modelMap: Record<string, string> = {
'deepseek-v4-flash': 'deepseek-v4-flash',
'sensenova-6.7-flash-lite': 'sensenova-6.7-flash-lite',
};
const providerKey = modelMap[model.toLowerCase()] || model;
const provider = this.providers.get(providerKey);
if (!this.usageStats[providerKey]) this.usageStats[providerKey] = { total: 0, success: 0, failed: 0 };
this.usageStats[providerKey].total++;
if (provider) {
try {
const reply = await provider.chat(messages, options);
if (typeof reply !== 'string' || reply.length === 0) {
throw new Error(`AI 返回内容为空: ${JSON.stringify(reply)}`);
}
this.usageStats[providerKey].success++;
return reply;
} catch (err: any) {
this.logger.error(`${provider.name} 调用失败: ${err.message}`);
this.usageStats[providerKey].failed++;
return this.fallback(messages);
}
}
return this.fallback(messages);
}
async *chatStream(model: string, messages: ChatMessage[], options?: ChatOptions): AsyncIterable<string> {
const modelMap: Record<string, string> = {
'deepseek-v4-flash': 'deepseek-v4-flash',
'sensenova-6.7-flash-lite': 'sensenova-6.7-flash-lite',
};
const providerKey = modelMap[model.toLowerCase()] || model;
const provider = this.providers.get(providerKey);
if (!this.usageStats[providerKey]) this.usageStats[providerKey] = { total: 0, success: 0, failed: 0 };
this.usageStats[providerKey].total++;
if (provider?.chatStream) {
try {
let hasContent = false;
for await (const chunk of provider.chatStream(messages, options)) {
hasContent = true;
yield chunk;
}
if (!hasContent) throw new Error('AI 返回内容为空');
this.usageStats[providerKey].success++;
} catch (err: any) {
this.logger.error(`${provider.name} 流式调用失败: ${err.message}`);
this.usageStats[providerKey].failed++;
yield this.fallback(messages);
}
} else if (provider) {
try {
const reply = await provider.chat(messages, options);
this.usageStats[providerKey].success++;
yield reply;
} catch (err: any) {
this.logger.error(`${provider.name} 调用失败: ${err.message}`);
this.usageStats[providerKey].failed++;
yield this.fallback(messages);
}
} else {
yield this.fallback(messages);
}
}
private fallback(messages: ChatMessage[]): string {
const lastMsg = typeof messages[messages.length - 1]?.content === 'string'
? messages[messages.length - 1]?.content as string : '';
const mockReplies: Record<string, string> = {
'你好': '你好!我是宇之然 AI 助手,很高兴为你服务!',
'hello': 'Hello! I am YuZhiRan AI assistant, nice to meet you!',
};
for (const [key, reply] of Object.entries(mockReplies)) {
if (lastMsg.toLowerCase().includes(key)) {
return reply;
}
}
if (lastMsg.includes('提示词') || lastMsg.includes('prompt')) {
return '好的提示词需要明确角色、任务、输出格式和约束条件。例如:"你是一名专业的文案编辑,请帮我优化以下产品描述,要求语言简洁有力,突出产品核心卖点,控制在200字以内。"';
}
if (lastMsg.includes('模型') || lastMsg.includes('大模型')) {
return '目前主流的 AI 大模型包括:OpenAI 的 GPT 系列、Anthropic 的 Claude 系列、Google 的 Gemini 系列,以及国内的 DeepSeek、通义千问、文心一言、GLM 等。各模型在语言理解、代码生成、逻辑推理等方面各有优势。';
}
const names = Array.from(this.providers.values()).map(p => p.name).join('、');
return `我是宇之然 AI 助手。关于"${lastMsg.slice(0, 50)}..."的问题,我已收到。当前 AI 沙箱处于模拟模式,请配置 API Key 以获取真实回复。已配置的 API:${names}`;
}
getModelInfo(model: string): ModelInfo {
const key = model.toLowerCase();
return MODEL_CATALOG[key] || { id: model, provider: '未知', capabilities: ['chat'], contextWindow: 4096 };
}
getUsageStats() {
return { ...this.usageStats };
}
getRegisteredProviders(): string[] {
return Array.from(this.providers.keys());
}
/** 返回沙箱前端可选的模型列表(仅返回有 provider 注册的模型) */
getSandboxModels() {
return SANDBOX_MODELS.filter(m => m.available);
}
}
class OpenAICompatibleProvider implements AIProvider {
name: string;
private apiKey: string;
private apiUrl: string;
private defaultModel: string;
constructor(apiKey: string, apiUrl: string, defaultModel: string, name?: string) {
this.apiKey = apiKey;
this.apiUrl = apiUrl;
this.defaultModel = defaultModel;
this.name = name || 'OpenAI 兼容接口';
}
async chat(messages: ChatMessage[], options?: ChatOptions): Promise<string> {
const res = await fetch(this.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
model: this.defaultModel,
messages,
temperature: options?.temperature ?? 0.7,
top_p: options?.top_p ?? 1,
max_tokens: options?.max_tokens ?? 2000,
}),
});
if (!res.ok) {
throw new Error(`${this.name} API error: ${res.status} ${await res.text()}`);
}
const data = await res.json() as any;
const msg = data.choices[0].message;
return msg.content || msg.reasoning_content || '';
}
async *chatStream(messages: ChatMessage[], options?: ChatOptions): AsyncIterable<string> {
const res = await fetch(this.apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`,
},
body: JSON.stringify({
model: this.defaultModel,
messages,
temperature: options?.temperature ?? 0.7,
top_p: options?.top_p ?? 1,
max_tokens: options?.max_tokens ?? 2000,
stream: true,
}),
});
if (!res.ok) {
throw new Error(`${this.name} API error: ${res.status} ${await res.text()}`);
}
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed || !trimmed.startsWith('data:')) continue;
const data = trimmed.slice(5).trim();
if (data === '[DONE]') return;
try {
const json = JSON.parse(data);
const delta = json.choices?.[0]?.delta || {};
const content = delta.content || delta.reasoning_content || '';
if (content) yield content;
} catch {}
}
}
}
}