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
@@ -0,0 +1,139 @@
'use client';
import { useEffect, useState } from 'react';
import { useT } from '@/i18n';
export interface LearningStage {
id: string;
tasks: string[];
sceneId: string;
model: string;
temperature: number;
}
export const LEARNING_STAGES: (LearningStage & { descKey: string; descDescKey: string; taskKeys: string[] })[] = [
{ id: 'welcome', descKey: 'stageWelcome', descDescKey: 'stageWelcomeDesc', taskKeys: ['taskSendMsg', 'taskTryStarter', 'taskReadReply'], sceneId: 'general-chat', model: 'general', temperature: 0.7, tasks: [] },
{ id: 'scene', descKey: 'stageScene', descDescKey: 'stageSceneDesc', taskKeys: ['taskSwitchCoding', 'taskSwitchWriting', 'taskSwitchStudy'], sceneId: 'coding', model: 'deepseek-v4-flash', temperature: 0.7, tasks: [] },
{ id: 'params', descKey: 'stageParams', descDescKey: 'stageParamsDesc', taskKeys: ['taskHighTemp', 'taskLowTemp', 'taskCompareTemp'], sceneId: 'general-chat', model: 'general', temperature: 0.9, tasks: [] },
{ id: 'models', descKey: 'stageModels', descDescKey: 'stageModelsDesc', taskKeys: ['taskSwitchModel', 'taskCompareModel'], sceneId: 'general-chat', model: 'deepseek-v4-flash', temperature: 0.7, tasks: [] },
{ id: 'prompts', descKey: 'stagePrompts', descDescKey: 'stagePromptsDesc', taskKeys: ['taskRolePrompt', 'taskStructured'], sceneId: 'general-chat', model: 'general', temperature: 0.5, tasks: [] },
{ id: 'master', descKey: 'stageMaster', descDescKey: 'stageMasterDesc', taskKeys: ['taskTryStarter', 'taskCodeExec', 'taskCommunity'], sceneId: 'coding', model: 'deepseek-v4-flash', temperature: 0.7, tasks: [] },
];
const STORAGE_KEY = 'sandbox_learning_progress';
interface StageProgress {
done: string[];
}
function loadProgress(): StageProgress {
if (typeof window === 'undefined') return { done: [] };
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return JSON.parse(raw);
} catch {}
return { done: [] };
}
function saveProgress(p: StageProgress) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(p));
}
interface Props {
onStartStage: (stage: (typeof LEARNING_STAGES)[0]) => void;
}
export default function LearningPath({ onStartStage }: Props) {
const t = useT();
const [progress, setProgress] = useState<StageProgress>({ done: [] });
useEffect(() => {
setProgress(loadProgress());
}, []);
function markDone(stageId: string) {
const next = { ...progress, done: progress.done.includes(stageId) ? progress.done : [...progress.done, stageId] };
setProgress(next);
saveProgress(next);
}
function resetAll() {
const next = { done: [] };
setProgress(next);
saveProgress(next);
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">{t.sandbox.learnPathDesc}</p>
<button onClick={resetAll} className="text-xs text-muted-foreground hover:text-foreground underline shrink-0 ml-2"></button>
</div>
<div className="w-full bg-muted rounded-full h-1.5">
<div className="bg-brand-600 h-1.5 rounded-full transition-all" style={{ width: `${(progress.done.length / LEARNING_STAGES.length) * 100}%` }} />
</div>
<div className="space-y-1.5">
{LEARNING_STAGES.map((stage, i) => {
const unlocked = i === 0 || progress.done.includes(LEARNING_STAGES[i - 1].id);
const completed = progress.done.includes(stage.id);
return (
<div key={stage.id}
className={`rounded-xl border p-3 transition-colors ${completed ? 'bg-green-500/5 border-green-200 dark:border-green-800' : unlocked ? 'bg-card border-border hover:border-brand-400' : 'bg-muted/30 border-border/50 opacity-50'}`}>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<span className={`w-6 h-6 rounded-full flex items-center justify-center text-xs font-bold shrink-0 ${completed ? 'bg-green-500 text-white' : unlocked ? 'bg-brand-600 text-white' : 'bg-muted-foreground/30 text-muted-foreground'}`}>
{completed ? '✓' : i + 1}
</span>
<button onClick={() => unlocked && !completed && onStartStage(stage)} disabled={!unlocked || completed}
className={`min-w-0 text-left ${unlocked && !completed ? 'cursor-pointer hover:opacity-80' : 'cursor-default'}`}>
<div className="text-sm font-medium text-foreground truncate">
{t.sandbox[stage.descKey as keyof typeof t.sandbox] as string}
</div>
<div className="text-xs text-muted-foreground truncate">
{t.sandbox[stage.descDescKey as keyof typeof t.sandbox] as string}
</div>
</button>
</div>
{completed ? (
<span className="text-xs text-green-600 font-medium shrink-0">{t.sandbox.stageDone}</span>
) : unlocked ? (
<button onClick={() => onStartStage(stage)}
className="text-xs px-2.5 py-1 bg-brand-600 text-white rounded-lg hover:bg-brand-700 shrink-0">
{t.sandbox.startPractice}
</button>
) : (
<span className="text-xs text-muted-foreground shrink-0">🔒</span>
)}
</div>
{unlocked && (
<div className="mt-2 space-y-1">
{stage.taskKeys.map(tk => (
<label key={tk} className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer">
<input type="checkbox" checked={progress.done.includes(tk)} onChange={() => {
const tasks = [...progress.done];
if (tasks.includes(tk)) {
const idx = tasks.indexOf(tk);
tasks.splice(idx, 1);
} else {
tasks.push(tk);
}
const next = { ...progress, done: tasks };
setProgress(next);
saveProgress(next);
if (stage.taskKeys.every(k => next.done.includes(k)) && !next.done.includes(stage.id)) {
markDone(stage.id);
}
}}
className="w-3 h-3 rounded border-border accent-brand-600" />
{t.sandbox[tk as keyof typeof t.sandbox] as string}
</label>
))}
</div>
)}
</div>
);
})}
</div>
</div>
);
}