feat: P1 学习技能包 — 可组合技能模块系统
后端: - SkillsModule (service + controller),8 个预置技能 - GET /skills (支持 category/difficulty/search 过滤) - GET /skills/:id /categories /difficulties 前端: - /skills 技能市场 — 分类/难度/搜索过滤 - /skills/[id] 技能详情 — system prompt、练习任务、starter - header 导航新增「技能」入口 - sandbox page 改为从 API 加载技能(替代硬编码 SCENES) - 支持 ?skill=xxx 直接加载指定技能 - useSearchParams Suspense 包装 全栈: 70 pages / 92 tests 全部通过
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect, FormEvent } from 'react';
|
||||
import { useState, useRef, useEffect, FormEvent, Suspense } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useAuth } from '@/lib/auth-context';
|
||||
import { getToken, apiFetch } from '@/lib/auth';
|
||||
import { AVAILABLE_MODELS, DEFAULT_MODEL } from '@/lib/models';
|
||||
import { DEFAULT_MODEL } from '@/lib/models';
|
||||
import { ModelSelector } from '@/components/ui/model-selector';
|
||||
import { useT } from '@/i18n';
|
||||
|
||||
@@ -35,18 +36,30 @@ function extractCodeBlocks(content: string): string[] {
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function sceneName(t: any, id: string) {
|
||||
return ({ general: t.sandbox.sceneGeneral, coding: t.sandbox.sceneCoding, writing: t.sandbox.sceneWriting, study: t.sandbox.sceneStudy, english: t.sandbox.sceneEnglish } as Record<string, string>)[id]
|
||||
export default function SandboxPageWrapper() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div className="h-8 w-48 bg-muted rounded-lg animate-pulse mb-4" />
|
||||
<div className="h-[65vh] bg-muted rounded-2xl animate-pulse" />
|
||||
</div>
|
||||
}>
|
||||
<SandboxPage />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SandboxPage() {
|
||||
function SandboxPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const t = useT();
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{ role: 'assistant', content: '你好!我是宇之然 AI 助手。你可以问我任何问题,我会尽力帮你解答。\n\n试试问我关于 AI、编程、写作、办公效率等方面的问题!' },
|
||||
]);
|
||||
const [input, setInput] = useState('');
|
||||
const [model, setModel] = useState(DEFAULT_MODEL);
|
||||
const [scene, setScene] = useState('general');
|
||||
const [scene, setScene] = useState('');
|
||||
const [SCENES, setSCENES] = useState<{ id: string; name: string; icon: string; systemPrompt: string; starters: string[] }[]>([]);
|
||||
const [skillsLoading, setSkillsLoading] = useState(true);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [showParams, setShowParams] = useState(false);
|
||||
const [temperature, setTemperature] = useState(0.7);
|
||||
@@ -63,14 +76,6 @@ export default function SandboxPage() {
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
const { isLoggedIn } = useAuth();
|
||||
|
||||
const SCENES = [
|
||||
{ id: 'general', icon: '💬', systemPrompt: '你是一个智能 AI 助手,请友好、准确地回答用户的问题。', starters: ['介绍一下你自己', '今天天气怎么样', '讲个笑话'] },
|
||||
{ id: 'coding', icon: '💻', systemPrompt: '你是一名资深软件工程师,擅长编程教学。请用清晰的代码示例和通俗的语言解释技术概念。回答时优先提供可运行的代码。', starters: ['用 Python 写一个二分查找', 'React 和 Vue 有什么区别', '帮我 Debug 这段代码'] },
|
||||
{ id: 'writing', icon: '✍️', systemPrompt: '你是一名专业的写作顾问,擅长各类文体写作。请根据用户需求提供高质量的文字内容,注意逻辑清晰、表达准确。', starters: ['帮我写一篇产品介绍', '润色这段文字', '写一封工作邮件'] },
|
||||
{ id: 'study', icon: '📚', systemPrompt: '你是一名耐心且知识渊博的老师。请用通俗易懂的方式解释复杂概念,善用类比和例子,鼓励用户深入提问。', starters: ['解释什么是机器学习', '讲一下 TCP/IP 协议', '怎么理解量子计算'] },
|
||||
{ id: 'english', icon: '🌍', systemPrompt: 'You are an English tutor. Help users improve their English. Respond primarily in Chinese but provide English examples. Correct grammar and offer better expressions.', starters: ['"However" 和 "Although" 的区别', '帮我翻译这段话', '检查语法错误'] },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
const tk = getToken();
|
||||
if (tk) {
|
||||
@@ -83,6 +88,19 @@ export default function SandboxPage() {
|
||||
}
|
||||
}, [isLoggedIn]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`${API_BASE}/skills`)
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
const scenes = (data.items || []).map((s: any) => ({ id: s.id, name: s.name, icon: s.icon, systemPrompt: s.systemPrompt, starters: s.starters }));
|
||||
setSCENES(scenes);
|
||||
const skillParam = searchParams.get('skill');
|
||||
const initialScene = scenes.find((s: any) => s.id === skillParam) ? skillParam : (scenes[0]?.id || '');
|
||||
setScene(initialScene);
|
||||
})
|
||||
.finally(() => setSkillsLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.some(m => m.role === 'user') && messagesContainerRef.current) {
|
||||
messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight;
|
||||
@@ -102,8 +120,9 @@ export default function SandboxPage() {
|
||||
|
||||
function handleSceneChange(sceneId: string) {
|
||||
setScene(sceneId);
|
||||
const s = SCENES.find(x => x.id === sceneId);
|
||||
setMessages([
|
||||
{ role: 'assistant', content: `欢迎来到 **${sceneName(t, sceneId)}** 模式!试试下面的问题,或者直接输入你的问题吧。` },
|
||||
{ role: 'assistant', content: `欢迎来到 **${s?.name || sceneId}** 模式!试试下面的问题,或者直接输入你的问题吧。` },
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -195,8 +214,9 @@ export default function SandboxPage() {
|
||||
function newChat() {
|
||||
setConversationId(crypto.randomUUID());
|
||||
setCurrentSessionId(null);
|
||||
const s = SCENES.find(x => x.id === scene);
|
||||
setMessages([
|
||||
{ role: 'assistant', content: `欢迎来到 **${sceneName(t, scene)}** 模式!试试下面的问题,或者直接输入你的问题吧。` },
|
||||
{ role: 'assistant', content: `欢迎来到 **${s?.name || scene}** 模式!试试下面的问题,或者直接输入你的问题吧。` },
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -332,7 +352,7 @@ export default function SandboxPage() {
|
||||
: 'bg-card text-muted-foreground border-border hover:border-brand-400 hover:text-foreground'
|
||||
}`}>
|
||||
<span>{s.icon}</span>
|
||||
<span>{sceneName(t, s.id)}</span>
|
||||
<span>{s.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user