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>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useT } from '@/i18n';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
|
||||
|
||||
interface SkillTask { label: string; prompt: string }
|
||||
interface Skill {
|
||||
id: string; name: string; description: string; icon: string;
|
||||
category: string; difficulty: string; systemPrompt: string;
|
||||
starters: string[]; tasks: SkillTask[]; tags: string[];
|
||||
prerequisites?: string[];
|
||||
}
|
||||
|
||||
export default function SkillDetailPage() {
|
||||
const params = useParams();
|
||||
const t = useT();
|
||||
const [skill, setSkill] = useState<Skill | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!params.id) return;
|
||||
fetch(`${API_BASE}/skills/${params.id}`)
|
||||
.then(r => r.json())
|
||||
.then(data => { if (data.id) setSkill(data); })
|
||||
.finally(() => setLoading(false));
|
||||
}, [params.id]);
|
||||
|
||||
if (loading) return (
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<Skeleton className="h-8 w-48 mb-6" />
|
||||
<Skeleton className="h-64 rounded-2xl" />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!skill) return (
|
||||
<div className="max-w-4xl mx-auto px-4 py-20 text-center">
|
||||
<p className="text-muted-foreground">技能不存在</p>
|
||||
<Link href="/skills" className="text-brand-600 hover:underline text-sm mt-4 inline-block">返回技能库</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<Link href="/skills" className="text-sm text-muted-foreground hover:text-brand-600 mb-4 inline-block">
|
||||
← {t.skills.title}
|
||||
</Link>
|
||||
|
||||
<div className="bg-card rounded-2xl border border-border p-6 mb-6">
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<span className="text-4xl">{skill.icon}</span>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold text-foreground">{skill.name}</h1>
|
||||
<p className="text-muted-foreground mt-1">{skill.description}</p>
|
||||
<div className="flex items-center gap-3 mt-3">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
skill.difficulty === 'beginner' ? 'bg-green-100 text-green-700' :
|
||||
skill.difficulty === 'intermediate' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>{(t.skills as any)[skill.difficulty]}</span>
|
||||
<span className="text-xs text-muted-foreground">{(t.skills.categories as any)[skill.category] || skill.category}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link href={`/sandbox?skill=${skill.id}`}
|
||||
className="inline-flex px-5 py-2.5 bg-brand-600 text-white rounded-xl text-sm font-medium hover:bg-brand-700 transition-colors">
|
||||
{t.skills.apply}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="bg-card rounded-2xl border border-border p-6">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-3">{t.skills.starters}</h2>
|
||||
<div className="space-y-2">
|
||||
{skill.starters.map((q, i) => (
|
||||
<div key={i} className="p-3 bg-muted rounded-xl text-sm text-muted-foreground">{q}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-card rounded-2xl border border-border p-6">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-3">{t.skills.tasks}</h2>
|
||||
<div className="space-y-3">
|
||||
{skill.tasks.map((task, i) => (
|
||||
<div key={i} className="p-3 border border-border rounded-xl">
|
||||
<div className="text-sm font-medium text-foreground mb-1">{task.label}</div>
|
||||
<div className="text-xs text-muted-foreground font-mono">{task.prompt}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{skill.prerequisites && skill.prerequisites.length > 0 && (
|
||||
<div className="mt-6 bg-card rounded-2xl border border-border p-6">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-3">{t.skills.prerequisites}</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{skill.prerequisites.map(pre => (
|
||||
<Link key={pre} href={`/skills/${pre}`}
|
||||
className="px-3 py-1.5 text-sm bg-muted text-foreground rounded-lg hover:bg-accent transition-colors">
|
||||
{pre}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import SkillDetailClient from './client';
|
||||
|
||||
export function generateStaticParams() {
|
||||
const skillIds = ['general-chat', 'coding', 'writing', 'study', 'english', 'prompt-engineering', 'data-analysis', 'career'];
|
||||
return skillIds.map(id => ({ id }));
|
||||
}
|
||||
|
||||
export default function SkillDetailPage() {
|
||||
return <SkillDetailClient />;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { useT } from '@/i18n';
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
|
||||
|
||||
interface Skill {
|
||||
id: string; name: string; description: string; icon: string;
|
||||
category: string; difficulty: string;
|
||||
starters: string[]; tags: string[];
|
||||
}
|
||||
|
||||
export default function SkillsPage() {
|
||||
const t = useT();
|
||||
const [skills, setSkills] = useState<Skill[]>([]);
|
||||
const [categories, setCategories] = useState<{ id: string; name: string }[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [category, setCategory] = useState('');
|
||||
const [difficulty, setDifficulty] = useState('');
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
fetch(`${API_BASE}/skills`).then(r => r.json()),
|
||||
fetch(`${API_BASE}/skills/categories`).then(r => r.json()),
|
||||
]).then(([skillsData, cats]) => {
|
||||
setSkills(skillsData.items || []);
|
||||
setCategories(cats || []);
|
||||
}).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams();
|
||||
if (category) params.set('category', category);
|
||||
if (difficulty) params.set('difficulty', difficulty);
|
||||
if (search) params.set('search', search);
|
||||
fetch(`${API_BASE}/skills?${params}`)
|
||||
.then(r => r.json())
|
||||
.then(data => setSkills(data.items || []));
|
||||
}, [category, difficulty, search]);
|
||||
|
||||
const difficulties = [
|
||||
{ id: 'beginner', name: t.skills.beginner },
|
||||
{ id: 'intermediate', name: t.skills.intermediate },
|
||||
{ id: 'advanced', name: t.skills.advanced },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-foreground">{t.skills.title}</h1>
|
||||
<p className="mt-2 text-muted-foreground">{t.skills.desc}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3 mb-8">
|
||||
<input type="text" value={search} onChange={e => setSearch(e.target.value)}
|
||||
placeholder={t.skills.search}
|
||||
className="px-3 py-2 border border-border rounded-lg text-sm bg-background text-foreground w-48" />
|
||||
<select value={category} onChange={e => setCategory(e.target.value)}
|
||||
className="px-3 py-2 border border-border rounded-lg text-sm bg-background text-foreground">
|
||||
<option value="">{t.skills.allCategories}</option>
|
||||
{categories.map(c => <option key={c.id} value={c.id}>{(t.skills.categories as any)[c.id] || c.name}</option>)}
|
||||
</select>
|
||||
<select value={difficulty} onChange={e => setDifficulty(e.target.value)}
|
||||
className="px-3 py-2 border border-border rounded-lg text-sm bg-background text-foreground">
|
||||
<option value="">{t.skills.allDifficulties}</option>
|
||||
{difficulties.map(d => <option key={d.id} value={d.id}>{d.name}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[1,2,3,4,5,6].map(i => <Skeleton key={i} className="h-40 rounded-2xl" />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{skills.map(skill => (
|
||||
<Link key={skill.id} href={`/skills/${skill.id}`}
|
||||
className="bg-card rounded-2xl border border-border p-6 hover:shadow-md transition-all hover:-translate-y-0.5 group">
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<span className="text-2xl">{skill.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-foreground group-hover:text-brand-600 transition-colors">{skill.name}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-0.5 line-clamp-2">{skill.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
skill.difficulty === 'beginner' ? 'bg-green-100 text-green-700' :
|
||||
skill.difficulty === 'intermediate' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{(t.skills as any)[skill.difficulty]}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{(t.skills.categories as any)[skill.category] || skill.category}</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{skill.tags.slice(0, 3).map(tag => (
|
||||
<span key={tag} className="text-xs px-1.5 py-0.5 bg-muted text-muted-foreground rounded">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ const navItems = [
|
||||
{ href: '/', label: '首页' },
|
||||
{ href: '/courses', label: '专题' },
|
||||
{ href: '/sandbox', label: '沙盒' },
|
||||
{ href: '/skills', label: '技能' },
|
||||
{ href: '/models', label: '模型' },
|
||||
{ href: '/prompts', label: '提示词' },
|
||||
{ href: '/contents', label: '文章' },
|
||||
|
||||
@@ -150,6 +150,28 @@ const en: Translations = {
|
||||
three: 'Three.js',
|
||||
console: 'Console Output',
|
||||
},
|
||||
skills: {
|
||||
title: 'Skill Library',
|
||||
desc: 'Composable AI learning skill modules',
|
||||
search: 'Search skills...',
|
||||
allCategories: 'All Categories',
|
||||
allDifficulties: 'All Levels',
|
||||
beginner: 'Beginner',
|
||||
intermediate: 'Intermediate',
|
||||
advanced: 'Advanced',
|
||||
tasks: 'Practice Tasks',
|
||||
starters: 'Try these questions',
|
||||
prerequisites: 'Prerequisites',
|
||||
apply: 'Use this skill',
|
||||
categories: {
|
||||
basic: 'Basic',
|
||||
technical: 'Technical',
|
||||
creative: 'Creative',
|
||||
education: 'Education',
|
||||
advanced: 'Advanced',
|
||||
career: 'Career',
|
||||
},
|
||||
},
|
||||
promptWorkshop: {
|
||||
title: 'Prompt Workshop',
|
||||
desc: 'Write, test, and optimize your prompts',
|
||||
|
||||
@@ -148,6 +148,28 @@ const zh = {
|
||||
three: '3D (Three.js)',
|
||||
console: '控制台输出',
|
||||
},
|
||||
skills: {
|
||||
title: '技能库',
|
||||
desc: '可组合的 AI 学习技能模块',
|
||||
search: '搜索技能...',
|
||||
allCategories: '全部分类',
|
||||
allDifficulties: '全部难度',
|
||||
beginner: '入门',
|
||||
intermediate: '中级',
|
||||
advanced: '高级',
|
||||
tasks: '练习任务',
|
||||
starters: '试试这些问题',
|
||||
prerequisites: '前置技能',
|
||||
apply: '使用此技能',
|
||||
categories: {
|
||||
basic: '基础',
|
||||
technical: '技术',
|
||||
creative: '创意',
|
||||
education: '教育',
|
||||
advanced: '进阶',
|
||||
career: '职业',
|
||||
},
|
||||
},
|
||||
promptWorkshop: {
|
||||
title: '提示词工坊',
|
||||
desc: '编写、测试、优化你的提示词',
|
||||
|
||||
Reference in New Issue
Block a user