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>
This commit is contained in:
@@ -24,15 +24,17 @@ interface AIProvider {
|
|||||||
chatStream?(messages: ChatMessage[], options?: ChatOptions): AsyncIterable<string>;
|
chatStream?(messages: ChatMessage[], options?: ChatOptions): AsyncIterable<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 仅收录沙箱实际可用的国内已备案大模型
|
||||||
const MODEL_CATALOG: Record<string, ModelInfo> = {
|
const MODEL_CATALOG: Record<string, ModelInfo> = {
|
||||||
'general': { id: 'general', provider: 'OpenAI 兼容', capabilities: ['chat', 'code'], contextWindow: 8192 },
|
'deepseek-v4-flash': { id: 'deepseek-v4-flash', provider: '硅基流动/DeepSeek', capabilities: ['chat', 'code'], contextWindow: 32768 },
|
||||||
'openai': { id: 'openai', provider: 'OpenAI 兼容', capabilities: ['chat', 'code'], contextWindow: 8192 },
|
|
||||||
'gpt-3.5': { id: 'gpt-3.5', provider: 'OpenAI', capabilities: ['chat', 'code'], contextWindow: 16384 },
|
|
||||||
'gpt-4': { id: 'gpt-4', provider: 'OpenAI', capabilities: ['chat', 'code', 'vision'], contextWindow: 32768 },
|
|
||||||
'deepseek-v4-flash': { id: 'deepseek-v4-flash', provider: '商汤科技', capabilities: ['chat', 'code'], contextWindow: 32768 },
|
|
||||||
'sensenova-6.7-flash-lite': { id: 'sensenova-6.7-flash-lite', provider: '商汤科技', 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()
|
@Injectable()
|
||||||
export class AIGatewayService {
|
export class AIGatewayService {
|
||||||
@@ -45,19 +47,21 @@ export class AIGatewayService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private registerProviders() {
|
private registerProviders() {
|
||||||
|
// 国内已备案大模型 — 通过硅基流动 / DeepSeek 直连等国内渠道
|
||||||
if (process.env.OPENAI_API_KEY) {
|
if (process.env.OPENAI_API_KEY) {
|
||||||
let apiUrl = process.env.OPENAI_API_URL || 'https://api.openai.com/v1/chat/completions';
|
let apiUrl = process.env.OPENAI_API_URL || 'https://api.openai.com/v1/chat/completions';
|
||||||
if (!apiUrl.endsWith('/chat/completions')) {
|
if (!apiUrl.endsWith('/chat/completions')) {
|
||||||
apiUrl = apiUrl.replace(/\/+$/, '') + '/chat/completions';
|
apiUrl = apiUrl.replace(/\/+$/, '') + '/chat/completions';
|
||||||
}
|
}
|
||||||
const defaultModel = process.env.OPENAI_MODEL || 'gpt-3.5-turbo';
|
const defaultModel = process.env.OPENAI_MODEL || 'deepseek/deepseek-v4-flash';
|
||||||
this.providers.set('openai', new OpenAICompatibleProvider(
|
this.providers.set('deepseek-v4-flash', new OpenAICompatibleProvider(
|
||||||
process.env.OPENAI_API_KEY!,
|
process.env.OPENAI_API_KEY!,
|
||||||
apiUrl,
|
apiUrl,
|
||||||
defaultModel,
|
defaultModel,
|
||||||
'OpenAI 兼容接口',
|
'硅基流动 / DeepSeek',
|
||||||
));
|
));
|
||||||
this.logger.log(`OpenAI 兼容接口已注册: ${defaultModel}`);
|
this.logger.log(`DeepSeek(通过国内渠道)已注册: ${defaultModel}`);
|
||||||
|
this.markAvailable('deepseek-v4-flash');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.env.SENSENOVA_API_KEY) {
|
if (process.env.SENSENOVA_API_KEY) {
|
||||||
@@ -73,25 +77,27 @@ export class AIGatewayService {
|
|||||||
modelName,
|
modelName,
|
||||||
'商汤科技',
|
'商汤科技',
|
||||||
));
|
));
|
||||||
|
this.markAvailable(modelName);
|
||||||
}
|
}
|
||||||
this.logger.log(`商汤科技已注册: ${models.join(', ')}`);
|
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> {
|
async chat(model: string, messages: ChatMessage[], options?: ChatOptions): Promise<string> {
|
||||||
|
// 仅路由到国内已备案模型的 provider
|
||||||
const modelMap: Record<string, string> = {
|
const modelMap: Record<string, string> = {
|
||||||
'general': 'openai',
|
|
||||||
'openai': 'openai',
|
|
||||||
'gpt-3.5': 'openai',
|
|
||||||
'gpt-4': 'openai',
|
|
||||||
'longcat': 'openai',
|
|
||||||
'meituan/longcat-flash-lite': 'openai',
|
|
||||||
'deepseek-v4-flash': 'deepseek-v4-flash',
|
'deepseek-v4-flash': 'deepseek-v4-flash',
|
||||||
'sensenova-6.7-flash-lite': 'sensenova-6.7-flash-lite',
|
'sensenova-6.7-flash-lite': 'sensenova-6.7-flash-lite',
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const providerKey = modelMap[model.toLowerCase()] || (model.includes('/') ? 'openai' : model);
|
const providerKey = modelMap[model.toLowerCase()] || model;
|
||||||
const provider = this.providers.get(providerKey);
|
const provider = this.providers.get(providerKey);
|
||||||
|
|
||||||
if (!this.usageStats[providerKey]) this.usageStats[providerKey] = { total: 0, success: 0, failed: 0 };
|
if (!this.usageStats[providerKey]) this.usageStats[providerKey] = { total: 0, success: 0, failed: 0 };
|
||||||
@@ -117,18 +123,11 @@ export class AIGatewayService {
|
|||||||
|
|
||||||
async *chatStream(model: string, messages: ChatMessage[], options?: ChatOptions): AsyncIterable<string> {
|
async *chatStream(model: string, messages: ChatMessage[], options?: ChatOptions): AsyncIterable<string> {
|
||||||
const modelMap: Record<string, string> = {
|
const modelMap: Record<string, string> = {
|
||||||
'general': 'openai',
|
|
||||||
'openai': 'openai',
|
|
||||||
'gpt-3.5': 'openai',
|
|
||||||
'gpt-4': 'openai',
|
|
||||||
'longcat': 'openai',
|
|
||||||
'meituan/longcat-flash-lite': 'openai',
|
|
||||||
'deepseek-v4-flash': 'deepseek-v4-flash',
|
'deepseek-v4-flash': 'deepseek-v4-flash',
|
||||||
'sensenova-6.7-flash-lite': 'sensenova-6.7-flash-lite',
|
'sensenova-6.7-flash-lite': 'sensenova-6.7-flash-lite',
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const providerKey = modelMap[model.toLowerCase()] || (model.includes('/') ? 'openai' : model);
|
const providerKey = modelMap[model.toLowerCase()] || model;
|
||||||
const provider = this.providers.get(providerKey);
|
const provider = this.providers.get(providerKey);
|
||||||
|
|
||||||
if (!this.usageStats[providerKey]) this.usageStats[providerKey] = { total: 0, success: 0, failed: 0 };
|
if (!this.usageStats[providerKey]) this.usageStats[providerKey] = { total: 0, success: 0, failed: 0 };
|
||||||
@@ -201,6 +200,11 @@ export class AIGatewayService {
|
|||||||
getRegisteredProviders(): string[] {
|
getRegisteredProviders(): string[] {
|
||||||
return Array.from(this.providers.keys());
|
return Array.from(this.providers.keys());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 返回沙箱前端可选的模型列表(仅返回有 provider 注册的模型) */
|
||||||
|
getSandboxModels() {
|
||||||
|
return SANDBOX_MODELS.filter(m => m.available);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class OpenAICompatibleProvider implements AIProvider {
|
class OpenAICompatibleProvider implements AIProvider {
|
||||||
|
|||||||
@@ -7,13 +7,18 @@ import { Response } from 'express';
|
|||||||
|
|
||||||
@ApiTags('AI沙箱')
|
@ApiTags('AI沙箱')
|
||||||
@Controller('sandbox')
|
@Controller('sandbox')
|
||||||
@UseGuards(AuthGuard('jwt'))
|
|
||||||
@ApiBearerAuth()
|
|
||||||
export class SandboxController {
|
export class SandboxController {
|
||||||
constructor(private sandboxService: SandboxService) {}
|
constructor(private sandboxService: SandboxService) {}
|
||||||
|
|
||||||
|
@Get('models')
|
||||||
|
async getModels() {
|
||||||
|
return { items: this.sandboxService.getAvailableModels() };
|
||||||
|
}
|
||||||
|
|
||||||
@Post('chat')
|
@Post('chat')
|
||||||
@ApiBody({ schema: { example: { conversationId: 'uuid', model: 'general', messages: [{ role: 'user', content: 'hi' }], images: ['https://example.com/img.png'], temperature: 0.7, top_p: 1, max_tokens: 2000, stream: false } } })
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiBody({ schema: { example: { conversationId: 'uuid', model: 'deepseek-v4-flash', messages: [{ role: 'user', content: 'hi' }], images: ['https://example.com/img.png'], temperature: 0.7, top_p: 1, max_tokens: 2000, stream: false } } })
|
||||||
async chat(@Req() req: any, @Body() body: { conversationId?: string; model: string; messages: { role: string; content: string }[]; images?: string[]; stream?: boolean } & ChatOptions, @Res() res: Response) {
|
async chat(@Req() req: any, @Body() body: { conversationId?: string; model: string; messages: { role: string; content: string }[]; images?: string[]; stream?: boolean } & ChatOptions, @Res() res: Response) {
|
||||||
if (body.stream) {
|
if (body.stream) {
|
||||||
res.setHeader('Content-Type', 'text/event-stream');
|
res.setHeader('Content-Type', 'text/event-stream');
|
||||||
@@ -35,36 +40,50 @@ export class SandboxController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('sessions')
|
@Get('sessions')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@ApiBearerAuth()
|
||||||
async sessions(@Req() req: any, @Query() query: { page?: number; pageSize?: number; search?: string }) {
|
async sessions(@Req() req: any, @Query() query: { page?: number; pageSize?: number; search?: string }) {
|
||||||
return this.sandboxService.getSessions(req.user.userId, query);
|
return this.sandboxService.getSessions(req.user.userId, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('sessions/:id')
|
@Get('sessions/:id')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@ApiBearerAuth()
|
||||||
async getSession(@Req() req: any, @Param('id', ParseIntPipe) id: number) {
|
async getSession(@Req() req: any, @Param('id', ParseIntPipe) id: number) {
|
||||||
return this.sandboxService.getSession(req.user.userId, id);
|
return this.sandboxService.getSession(req.user.userId, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch('sessions/:id/feedback')
|
@Patch('sessions/:id/feedback')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@ApiBearerAuth()
|
||||||
async setFeedback(@Req() req: any, @Param('id', ParseIntPipe) id: number, @Body() body: { feedback: 'LIKE' | 'DISLIKE' | null }) {
|
async setFeedback(@Req() req: any, @Param('id', ParseIntPipe) id: number, @Body() body: { feedback: 'LIKE' | 'DISLIKE' | null }) {
|
||||||
return this.sandboxService.setFeedback(req.user.userId, id, body.feedback);
|
return this.sandboxService.setFeedback(req.user.userId, id, body.feedback);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Delete('sessions/:id')
|
@Delete('sessions/:id')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@ApiBearerAuth()
|
||||||
async deleteSession(@Req() req: any, @Param('id', ParseIntPipe) id: number) {
|
async deleteSession(@Req() req: any, @Param('id', ParseIntPipe) id: number) {
|
||||||
return this.sandboxService.deleteSession(req.user.userId, id);
|
return this.sandboxService.deleteSession(req.user.userId, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('quota')
|
@Get('quota')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@ApiBearerAuth()
|
||||||
async quota(@Req() req: any) {
|
async quota(@Req() req: any) {
|
||||||
return this.sandboxService.getQuota(req.user.userId);
|
return this.sandboxService.getQuota(req.user.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('sessions/:id/share')
|
@Post('sessions/:id/share')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@ApiBearerAuth()
|
||||||
async share(@Req() req: any, @Param('id', ParseIntPipe) id: number) {
|
async share(@Req() req: any, @Param('id', ParseIntPipe) id: number) {
|
||||||
return this.sandboxService.generateShareToken(req.user.userId, id);
|
return this.sandboxService.generateShareToken(req.user.userId, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch('sessions/:id/rename')
|
@Patch('sessions/:id/rename')
|
||||||
|
@UseGuards(AuthGuard('jwt'))
|
||||||
|
@ApiBearerAuth()
|
||||||
async rename(@Req() req: any, @Param('id', ParseIntPipe) id: number, @Body() body: { title: string }) {
|
async rename(@Req() req: any, @Param('id', ParseIntPipe) id: number, @Body() body: { title: string }) {
|
||||||
return this.sandboxService.renameSession(req.user.userId, id, body.title);
|
return this.sandboxService.renameSession(req.user.userId, id, body.title);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,13 @@ import { PrismaService } from '../../prisma/prisma.service';
|
|||||||
import { AIGatewayService, ChatOptions } from '../ai/ai-gateway.service';
|
import { AIGatewayService, ChatOptions } from '../ai/ai-gateway.service';
|
||||||
import { randomUUID, createHmac } from 'crypto';
|
import { randomUUID, createHmac } from 'crypto';
|
||||||
|
|
||||||
|
export interface SandboxModelInfo {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
provider: string;
|
||||||
|
desc: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface StreamResult {
|
interface StreamResult {
|
||||||
type: 'text' | 'done' | 'error';
|
type: 'text' | 'done' | 'error';
|
||||||
content?: string;
|
content?: string;
|
||||||
@@ -288,6 +295,16 @@ export class SandboxService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 返回沙箱中可用的模型列表 */
|
||||||
|
getAvailableModels(): SandboxModelInfo[] {
|
||||||
|
return this.aiGateway.getSandboxModels().map(m => ({
|
||||||
|
id: m.id,
|
||||||
|
label: m.label,
|
||||||
|
provider: m.provider,
|
||||||
|
desc: m.desc,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
async generateShareToken(userId: number, sessionId: number) {
|
async generateShareToken(userId: number, sessionId: number) {
|
||||||
const session = await this.prisma.sandboxSession.findFirst({
|
const session = await this.prisma.sandboxSession.findFirst({
|
||||||
where: { id: sessionId, userId },
|
where: { id: sessionId, userId },
|
||||||
|
|||||||
Reference in New Issue
Block a user