cd0ef01c7c
- 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>
296 lines
10 KiB
TypeScript
Executable File
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 {}
|
|
}
|
|
}
|
|
}
|
|
}
|