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
@@ -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);