feat: AI沙箱流式对话+引导学习+静态服务优化
- 后端: AI沙箱流式对话(SSE), reasoning_content 回退支持 - 前端: 沙箱页流式渲染, 引导学习面板, 默认场景/Starter兜底 - 优化: 前端改用静态文件服务器(node server.js)替代next dev, CSS永不丢失 - 修复: 通用模型默认改为可用模型, predev不再删.next缓存
This commit is contained in:
@@ -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 {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user