feat: AI沙箱流式对话+引导学习+静态服务优化

- 后端: AI沙箱流式对话(SSE), reasoning_content 回退支持
- 前端: 沙箱页流式渲染, 引导学习面板, 默认场景/Starter兜底
- 优化: 前端改用静态文件服务器(node server.js)替代next dev, CSS永不丢失
- 修复: 通用模型默认改为可用模型, predev不再删.next缓存
This commit is contained in:
yuzhiran-dev
2026-05-27 18:26:30 +08:00
parent 0b66d752ce
commit 417fb266d4
14 changed files with 947 additions and 203 deletions
@@ -107,13 +107,18 @@ export class OperationsController {
}
@Put('config/:key')
async updateConfig(@Param('key') key: string, @Body() body: { value: string }) {
async updateConfig(@Param('key') key: string, @Body() body: { value: string; description?: string; category?: string }) {
const existing = await this.prisma.systemConfig.findUnique({ where: { key } });
if (existing) {
return this.prisma.systemConfig.update({ where: { key }, data: { value: body.value } });
return this.prisma.systemConfig.update({ where: { key }, data: { value: body.value, ...(body.description !== undefined ? { description: body.description } : {}) } });
}
return this.prisma.systemConfig.create({
data: { key, value: body.value, category: 'other' },
data: { key, value: body.value, category: body.category || 'other', description: body.description || '' },
});
}
@Delete('config/:key')
async deleteConfig(@Param('key') key: string) {
return this.prisma.systemConfig.delete({ where: { key } });
}
}
+99 -1
View File
@@ -21,6 +21,7 @@ export interface ModelInfo {
interface AIProvider {
name: string;
chat(messages: ChatMessage[], options?: ChatOptions): Promise<string>;
chatStream?(messages: ChatMessage[], options?: ChatOptions): AsyncIterable<string>;
}
const MODEL_CATALOG: Record<string, ModelInfo> = {
@@ -114,6 +115,54 @@ export class AIGatewayService {
return this.fallback(messages);
}
async *chatStream(model: string, messages: ChatMessage[], options?: ChatOptions): AsyncIterable<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',
'sensenova-6.7-flash-lite': 'sensenova-6.7-flash-lite',
'sensenova-u1-fast': 'sensenova-u1-fast',
};
const providerKey = modelMap[model.toLowerCase()] || (model.includes('/') ? 'openai' : 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 : '';
@@ -188,6 +237,55 @@ class OpenAICompatibleProvider implements AIProvider {
}
const data = await res.json() as any;
return data.choices[0].message.content;
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 {}
}
}
}
}
@@ -1,8 +1,9 @@
import { Controller, Post, Get, Delete, Patch, Body, Param, Query, UseGuards, Req, ParseIntPipe } from '@nestjs/common';
import { Controller, Post, Get, Delete, Patch, Body, Param, Query, UseGuards, Req, ParseIntPipe, Res } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiBody } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { SandboxService } from './sandbox.service';
import { ChatOptions } from '../ai/ai-gateway.service';
import { Response } from 'express';
@ApiTags('AI沙箱')
@Controller('sandbox')
@@ -12,9 +13,25 @@ export class SandboxController {
constructor(private sandboxService: SandboxService) {}
@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 } } })
async chat(@Req() req: any, @Body() body: { conversationId?: string; model: string; messages: { role: string; content: string }[]; images?: string[] } & ChatOptions) {
return this.sandboxService.chat(req.user.userId, body.conversationId, body.model, body.messages, body, body.images);
@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 } } })
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) {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
try {
for await (const chunk of this.sandboxService.chatStream(req.user.userId, body.conversationId, body.model, body.messages, body, body.images)) {
res.write(`data: ${chunk}\n\n`);
}
} catch (err: any) {
res.write(`data: ${JSON.stringify({ type: 'error', message: err.message })}\n\n`);
}
res.end();
} else {
return this.sandboxService.chat(req.user.userId, body.conversationId, body.model, body.messages, body, body.images);
}
}
@Get('sessions')
@@ -3,6 +3,14 @@ import { PrismaService } from '../../prisma/prisma.service';
import { AIGatewayService, ChatOptions } from '../ai/ai-gateway.service';
import { randomUUID, createHmac } from 'crypto';
interface StreamResult {
type: 'text' | 'done' | 'error';
content?: string;
sessionId?: number;
conversationId?: string;
message?: string;
}
@Injectable()
export class SandboxService {
constructor(
@@ -78,6 +86,84 @@ export class SandboxService {
return { reply, conversationId: convId, sessionId: session.id };
}
async *chatStream(userId: number, conversationId: string | undefined, model: string, messages: { role: string; content: string }[], options?: ChatOptions, images?: string[]): AsyncGenerator<string> {
const user = await this.prisma.user.findUnique({ where: { id: userId } });
if (!user || user.status !== 'ACTIVE') {
yield JSON.stringify({ type: 'error', message: '用户不可用' } as StreamResult);
return;
}
const convId = conversationId || randomUUID();
const today = new Date();
today.setHours(0, 0, 0, 0);
const existing = await this.prisma.sandboxSession.findUnique({
where: { userId_conversationId: { userId, conversationId: convId } },
});
if (!existing) {
const todayCount = await this.prisma.sandboxSession.count({
where: { userId, createdAt: { gte: today } },
});
if (todayCount >= (user.sandboxDaily || 10)) {
yield JSON.stringify({ type: 'error', message: '今日沙箱使用次数已用完' } as StreamResult);
return;
}
}
let aiMessages = messages as any[];
if (images && images.length > 0) {
aiMessages = messages.map(m => {
if (m.role === 'user' && m === messages[messages.length - 1]) {
const parts: any[] = [{ type: 'text', text: m.content }];
for (const img of images) {
parts.push({ type: 'image_url', image_url: { url: img } });
}
return { role: m.role, content: parts };
}
return m;
});
}
let fullReply = '';
try {
for await (const chunk of this.aiGateway.chatStream(model, aiMessages, options)) {
fullReply += chunk;
yield JSON.stringify({ type: 'text', content: chunk } as StreamResult);
}
} catch (err: any) {
yield JSON.stringify({ type: 'error', message: err.message } as StreamResult);
return;
}
if (typeof fullReply !== 'string' || fullReply.length === 0) {
fullReply = '抱歉,AI 返回了无效的回复,请重试。';
}
const allMessages = messages.concat({ role: 'assistant', content: fullReply });
const firstUserMsg = messages.find(m => m.role === 'user');
const title = firstUserMsg ? firstUserMsg.content.slice(0, 80) : 'AI 对话';
const session = await this.prisma.sandboxSession.upsert({
where: { userId_conversationId: { userId, conversationId: convId } },
create: {
userId,
conversationId: convId,
model,
title,
messages: JSON.stringify(allMessages),
tokens: Math.ceil(fullReply.length / 2),
},
update: {
model,
title,
messages: JSON.stringify(allMessages),
tokens: Math.ceil(fullReply.length / 2),
},
});
yield JSON.stringify({ type: 'done', sessionId: session.id, conversationId: convId } as StreamResult);
}
async getSessions(userId: number, params: { page?: number; pageSize?: number; search?: string }) {
const page = Number(params.page ?? 1);
const pageSize = Number(params.pageSize ?? 50);