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
+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 {}
}
}
}
}