feat: 完成教学设计模块(除了课程图谱),添加了炫酷的思考中动画
Some checks failed
CI / test (push) Failing after 1m12s
CI / lint (push) Failing after 14m36s

This commit is contained in:
Timothy Yin 2025-04-27 18:51:40 +08:00
parent 20471bfbe3
commit 49b9e97ee8
Signed by: HoshinoSuzumi
GPG Key ID: 4052E565F04B122A
12 changed files with 833 additions and 28 deletions

View File

@ -44,6 +44,6 @@ defineProps({
<style>
think {
@apply block my-4 p-3 bg-[#f0f8ff] border-l-4 border-[#88f] rounded italic text-xs;
@apply block my-4 p-3 bg-blue-500/5 border-l-4 border-primary/30 rounded italic text-xs;
}
</style>

View File

@ -10,7 +10,7 @@ const props = defineProps<{
form: FormContext<any>
formFieldConfig?: {
[key: string]: {
component: string
component?: string
props?: Record<string, any>
[key: string]: any
}
@ -20,6 +20,15 @@ const props = defineProps<{
disableUserInput?: boolean
}>()
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const currentConversation = computed(() => {
const { conversations, activeConversationId } = props
if (conversations && activeConversationId) {
return conversations.find((m) => m.id === activeConversationId)
}
return null
})
const messages = computed(() => {
const { conversations, activeConversationId } = props
if (conversations && activeConversationId) {
@ -115,7 +124,9 @@ const onDeleteConversation = (conversationId: string) => {
</span>
</div>
</div>
<p class="text-xs text-muted-foreground/60 font-medium text-center pt-2">
<p
class="text-xs text-muted-foreground/60 font-medium text-center pt-2"
>
到底了
</p>
</ScrollArea>
@ -150,22 +161,33 @@ const onDeleteConversation = (conversationId: string) => {
:class="`${message.role == 'user' ? 'justify-end' : 'justify-start'}`"
>
<div
class="w-fit px-4 py-3 rounded-lg bg-white dark:bg-gray-800 shadow max-w-prose border"
:class="`${message.role == 'user' ? 'rounded-br-none' : 'rounded-bl-none'}`"
class="gradient-border"
:class="
message.role === 'assistant' && !currentConversation?.finished_at && message.content
? ''
: 'inactive'
"
>
<MarkdownRenderer
v-if="!!message.content"
:source="message.content"
/>
<div
v-else
class="flex items-center gap-2 text-foreground/60 text-sm font-medium"
class="w-fit px-4 py-3 rounded-lg max-w-prose shadow bg-white dark:bg-gray-800 border"
:class="[
message.role == 'user' ? 'rounded-br-none' : 'rounded-bl-none',
]"
>
<Icon
name="svg-spinners:270-ring-with-bg"
class="text-lg"
<MarkdownRenderer
v-if="!!message.content"
:source="message.content"
/>
思考中
<div
v-else
class="flex items-center gap-2 text-foreground/60 text-sm font-medium"
>
<Icon
name="svg-spinners:270-ring-with-bg"
class="text-lg"
/>
思考中
</div>
</div>
</div>
</div>
@ -202,7 +224,7 @@ const onDeleteConversation = (conversationId: string) => {
:form="form"
:field-config="formFieldConfig"
class="space-y-2"
@submit="(values) => $emit('submit', values)"
@submit="(values: any) => $emit('submit', values)"
>
<div class="w-full flex justify-center gap-2 pt-4">
<Button
@ -219,4 +241,41 @@ const onDeleteConversation = (conversationId: string) => {
</div>
</template>
<style scoped></style>
<style scoped>
@property --angle {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
.gradient-border {
position: relative;
border-radius: 0.5rem;
padding: 2px;
overflow: hidden;
background: conic-gradient(
from var(--angle),
hsla(188, 86%, 53%, 0.8),
hsla(142, 69%, 58%, 0.8),
hsla(48, 96%, 53%, 0.8),
hsla(329, 86%, 70%, 0.8),
hsla(188, 86%, 53%, 0.8)
);
animation: rotate 2s linear infinite;
}
.gradient-border.inactive {
background: transparent;
}
.gradient-border > * {
border-radius: calc(0.5rem - 2px);
position: relative;
}
@keyframes rotate {
to {
--angle: 360deg;
}
}
</style>

View File

@ -0,0 +1,126 @@
<script lang="ts" setup>
import { toTypedSchema } from '@vee-validate/zod'
import { useForm } from 'vee-validate'
import { toast } from 'vue-sonner'
import { z } from 'zod'
const route = useRoute()
const router = useRouter()
const historyStore = useLlmHistories('course-chapter-gen')
const { conversations } = storeToRefs(historyStore)
const { updateConversation, appendChunkToLast, isConversationExist } =
historyStore
const activeConversationId = ref<string | null>(null)
watch(activeConversationId, (val) => {
if (val) {
router.replace({
query: {
fn: route.query.fn,
conversationId: val,
},
})
} else {
router.replace({
query: {
fn: route.query.fn,
},
})
}
})
onMounted(() => {
if (route.query.conversationId) {
if (isConversationExist(route.query.conversationId as string)) {
activeConversationId.value = route.query.conversationId as string
} else {
activeConversationId.value = null
toast.error('会话不存在')
}
}
})
const schema = z.object({
target: z.string({ required_error: '请输入授课对象' }).describe('授课对象'),
topic: z.string({ required_error: '请输入课程主题' }).describe('课程主题'),
goal: z.string({ required_error: '请输入课程目标' }).describe('课程目标'),
requirement: z.string().describe('其他要求').optional(),
})
const form = useForm({
validationSchema: toTypedSchema(schema),
})
const onSubmit = (values: z.infer<typeof schema>) => {
http_stream<z.infer<typeof schema>>('/ai/course-chapter/stream', values, {
onStart(id, created_at) {
activeConversationId.value = id
updateConversation(id, {
id,
created_at,
title: values.target,
messages: [
{
role: 'user',
content: `生成课程章节大纲\n授课对象${values.target}\n课程主题${values.topic}\n课程目标${values.goal}\n其他要求${values.requirement ?? '无'}`,
},
{
role: 'assistant',
content: '',
},
],
})
},
onTextChunk: (chunk) => {
appendChunkToLast(activeConversationId.value!, chunk)
},
onComplete: (id, finished_at) => {
updateConversation(id!, {
finished_at,
})
},
})
}
</script>
<template>
<div>
<AiConversation
:form
:form-schema="schema"
:form-field-config="{
target: {
inputProps: {
placeholder: '请输入授课对象,如:本科生、研究生',
},
},
topic: {
inputProps: {
placeholder: '请输入课程主题,如:数据结构与算法',
},
},
goal: {
inputProps: {
placeholder: '请输入课程目标,如:掌握数据结构与算法的基本概念',
},
},
requirement: {
component: 'textarea',
inputProps: {
placeholder: '请输入其他要求,如:教学重点',
},
},
}"
:conversations
:active-conversation-id="activeConversationId"
disable-user-input
@submit="onSubmit"
@update:conversation-id="activeConversationId = $event"
@delete-conversation="historyStore.removeConversation($event)"
/>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,133 @@
<script lang="ts" setup>
import { toTypedSchema } from '@vee-validate/zod'
import { useForm } from 'vee-validate'
import { toast } from 'vue-sonner'
import { z } from 'zod'
const route = useRoute()
const router = useRouter()
const historyStore = useLlmHistories('course-course-outline')
const { conversations } = storeToRefs(historyStore)
const { updateConversation, appendChunkToLast, isConversationExist } =
historyStore
const activeConversationId = ref<string | null>(null)
watch(activeConversationId, (val) => {
if (val) {
router.replace({
query: {
fn: route.query.fn,
conversationId: val,
},
})
} else {
router.replace({
query: {
fn: route.query.fn,
},
})
}
})
onMounted(() => {
if (route.query.conversationId) {
if (isConversationExist(route.query.conversationId as string)) {
activeConversationId.value = route.query.conversationId as string
} else {
activeConversationId.value = null
toast.error('会话不存在')
}
}
})
const schema = z.object({
courseName: z
.string({ required_error: '请输入课程名称' })
.describe('课程名称'),
targetAudience: z
.string({ required_error: '请输入授课对象' })
.describe('授课对象'),
courseObjective: z
.string({ required_error: '请输入课程目标' })
.describe('课程目标'),
otherRequirement: z.string().describe('其他要求').optional(),
})
const form = useForm({
validationSchema: toTypedSchema(schema),
})
const onSubmit = (values: z.infer<typeof schema>) => {
http_stream<z.infer<typeof schema>>('/ai/curriculum-outline/stream', values, {
onStart(id, created_at) {
activeConversationId.value = id
updateConversation(id, {
id,
created_at,
title: values.courseName,
messages: [
{
role: 'user',
content: `*生成一份 ${values.courseName} 的课程大纲*`,
},
{
role: 'assistant',
content: '',
},
],
})
},
onTextChunk: (chunk) => {
appendChunkToLast(activeConversationId.value!, chunk)
},
onComplete: (id, finished_at) => {
updateConversation(id!, {
finished_at,
})
},
})
}
</script>
<template>
<div>
<AiConversation
:form
:form-schema="schema"
:form-field-config="{
courseName: {
inputProps: {
placeholder: '请输入课程名称,如:数据结构与算法',
},
},
targetAudience: {
inputProps: {
placeholder: '请输入授课对象,如:大一物联网专业学生',
},
},
courseObjective: {
component: 'textarea',
inputProps: {
placeholder:
'例如:知识目标需掌握和理解的理论知识、概念、原理、理论框架等;技能目标需掌握学科中所需的基本操作技能,如实验操作、计算机操作、语言表达等',
},
},
otherRequirement: {
inputProps: {
placeholder: '请输入其他要求,如:需要包含案例分析',
},
},
}"
:conversations
:active-conversation-id="activeConversationId"
disable-user-input
@submit="onSubmit"
@update:conversation-id="activeConversationId = $event"
@delete-conversation="historyStore.removeConversation($event)"
/>
</div>
</template>
<style scoped></style>

View File

@ -1,16 +1,93 @@
<script lang="ts" setup>
import { toTypedSchema } from '@vee-validate/zod'
import { useForm } from 'vee-validate'
import { toast } from 'vue-sonner'
import { z } from 'zod'
const route = useRoute()
const router = useRouter()
const historyStore = useLlmHistories('course-std-design')
const { conversations } = storeToRefs(historyStore)
const {
updateConversation,
appendChunkToLast,
isConversationExist
} = historyStore
const activeConversationId = ref<string | null>(null)
watch(activeConversationId, (val) => {
if (val) {
router.replace({
query: {
fn: route.query.fn,
conversationId: val,
},
})
} else {
router.replace({
query: {
fn: route.query.fn,
},
})
}
})
onMounted(() => {
if (route.query.conversationId) {
if (isConversationExist(route.query.conversationId as string)) {
activeConversationId.value = route.query.conversationId as string
} else {
activeConversationId.value = null
toast.error('会话不存在')
}
}
})
const schema = z.object({
foo: z.string().describe('测试Label').optional(),
bar: z.number(),
file: z.string({ required_error: '请选择课件文件' }).describe('课件文件'),
requirement: z.string().describe('其他要求').optional(),
})
const form = useForm({
validationSchema: toTypedSchema(schema),
})
const onSubmit = (values: z.infer<typeof schema>) => {
http_stream<z.infer<typeof schema>>(
'/ai/course-standard/stream',
values,
{
onStart(id, created_at) {
activeConversationId.value = id
updateConversation(id, {
id,
created_at,
title: `知识图谱`,
messages: [
{
role: 'user',
content: `*根据上传的课件生成一份知识图谱*`,
},
{
role: 'assistant',
content: '',
},
],
})
},
onTextChunk: (chunk) => {
appendChunkToLast(activeConversationId.value!, chunk)
},
onComplete: (id, finished_at) => {
updateConversation(id!, {
finished_at,
})
},
},
)
}
</script>
<template>
@ -18,6 +95,25 @@ const form = useForm({
<AiConversation
:form
:form-schema="schema"
:form-field-config="{
file: {
component: 'file',
inputProps: {
accept: 'application/pdf,application/vnd.ms-powerpoint,application/vnd.openxmlformats-officedocument.presentationml.presentation',
},
},
requirement: {
inputProps: {
placeholder: '请输入其他要求',
},
},
}"
:conversations
:active-conversation-id="activeConversationId"
disable-user-input
@submit="onSubmit"
@update:conversation-id="activeConversationId = $event"
@delete-conversation="historyStore.removeConversation($event)"
/>
</div>
</template>

View File

@ -0,0 +1,131 @@
<script lang="ts" setup>
import { toTypedSchema } from '@vee-validate/zod'
import { useForm } from 'vee-validate'
import { toast } from 'vue-sonner'
import { z } from 'zod'
const route = useRoute()
const router = useRouter()
const historyStore = useLlmHistories('course-teaching-plan')
const { conversations } = storeToRefs(historyStore)
const { updateConversation, appendChunkToLast, isConversationExist } =
historyStore
const activeConversationId = ref<string | null>(null)
watch(activeConversationId, (val) => {
if (val) {
router.replace({
query: {
fn: route.query.fn,
conversationId: val,
},
})
} else {
router.replace({
query: {
fn: route.query.fn,
},
})
}
})
onMounted(() => {
if (route.query.conversationId) {
if (isConversationExist(route.query.conversationId as string)) {
activeConversationId.value = route.query.conversationId as string
} else {
activeConversationId.value = null
toast.error('会话不存在')
}
}
})
const schema = z.object({
identity: z.string({ required_error: '请输入你的身份' }).describe('身份'),
mainPlan: z.string({ required_error: '请输入主要计划' }).describe('主要计划'),
planTemplate: z
.string({ required_error: '请输入教学计划模板' })
.describe('教学计划模板'),
otherRequirement: z.string().describe('其他要求').optional(),
})
const form = useForm({
validationSchema: toTypedSchema(schema),
})
const onSubmit = (values: z.infer<typeof schema>) => {
http_stream<z.infer<typeof schema>>('/ai/teaching-plan/stream', values, {
onStart(id, created_at) {
activeConversationId.value = id
updateConversation(id, {
id,
created_at,
title: values.mainPlan,
messages: [
{
role: 'user',
content: `*生成一份教学计划*`,
},
{
role: 'assistant',
content: '',
},
],
})
},
onTextChunk: (chunk) => {
appendChunkToLast(activeConversationId.value!, chunk)
},
onComplete: (id, finished_at) => {
updateConversation(id!, {
finished_at,
})
},
})
}
</script>
<template>
<div>
<AiConversation
:form
:form-schema="schema"
:form-field-config="{
identity: {
inputProps: {
placeholder: '请输入你的身份,如:高中数学教师、大学物理教师',
},
},
mainPlan: {
component: 'textarea',
inputProps: {
placeholder:
'请输入主要计划,如:课程目标、教学内容安排、教学方法设计',
},
},
planTemplate: {
component: 'textarea',
inputProps: {
placeholder:
'请输入教学计划模板,如:教学目标、教学进度、考核评估方式',
},
},
otherRequirement: {
inputProps: {
placeholder: '请输入其他要求',
},
},
}"
:conversations
:active-conversation-id="activeConversationId"
disable-user-input
@submit="onSubmit"
@update:conversation-id="activeConversationId = $event"
@delete-conversation="historyStore.removeConversation($event)"
/>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,107 @@
<script lang="ts" setup>
import { toTypedSchema } from '@vee-validate/zod'
import { useForm } from 'vee-validate'
import { toast } from 'vue-sonner'
import { z } from 'zod'
const route = useRoute()
const router = useRouter()
const historyStore = useLlmHistories('course-political-case')
const { conversations } = storeToRefs(historyStore)
const { updateConversation, appendChunkToLast, isConversationExist } =
historyStore
const activeConversationId = ref<string | null>(null)
watch(activeConversationId, (val) => {
if (val) {
router.replace({
query: {
fn: route.query.fn,
conversationId: val,
},
})
} else {
router.replace({
query: {
fn: route.query.fn,
},
})
}
})
onMounted(() => {
if (route.query.conversationId) {
if (isConversationExist(route.query.conversationId as string)) {
activeConversationId.value = route.query.conversationId as string
} else {
activeConversationId.value = null
toast.error('会话不存在')
}
}
})
const schema = z.object({
query: z.string({ required_error: '请输入教学需求' }).describe('教学需求'),
})
const form = useForm({
validationSchema: toTypedSchema(schema),
})
const onSubmit = (values: z.infer<typeof schema>) => {
http_stream<z.infer<typeof schema>>('/ai/ideological-case/stream', values, {
onStart(id, created_at) {
activeConversationId.value = id
updateConversation(id, {
id,
created_at,
title: values.query,
messages: [
{
role: 'user',
content: `*生成一份关于 ${values.query} 的思政案例*`,
},
{
role: 'assistant',
content: '',
},
],
})
},
onTextChunk: (chunk) => {
appendChunkToLast(activeConversationId.value!, chunk)
},
onComplete: (id, finished_at) => {
updateConversation(id!, {
finished_at,
})
},
})
}
</script>
<template>
<div>
<AiConversation
:form
:form-schema="schema"
:form-field-config="{
query: {
inputProps: {
placeholder: '请输入教学需求和其他要求',
},
},
}"
:conversations
:active-conversation-id="activeConversationId"
disable-user-input
@submit="onSubmit"
@update:conversation-id="activeConversationId = $event"
@delete-conversation="historyStore.removeConversation($event)"
/>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,137 @@
<script lang="ts" setup>
import { toTypedSchema } from '@vee-validate/zod'
import { useForm } from 'vee-validate'
import { toast } from 'vue-sonner'
import { z } from 'zod'
const route = useRoute()
const router = useRouter()
const historyStore = useLlmHistories('course-research-plan')
const { conversations } = storeToRefs(historyStore)
const { updateConversation, appendChunkToLast, isConversationExist } =
historyStore
const activeConversationId = ref<string | null>(null)
watch(activeConversationId, (val) => {
if (val) {
router.replace({
query: {
fn: route.query.fn,
conversationId: val,
},
})
} else {
router.replace({
query: {
fn: route.query.fn,
},
})
}
})
onMounted(() => {
if (route.query.conversationId) {
if (isConversationExist(route.query.conversationId as string)) {
activeConversationId.value = route.query.conversationId as string
} else {
activeConversationId.value = null
toast.error('会话不存在')
}
}
})
const schema = z.object({
subject: z.string({ required_error: '请输入学科名称' }).describe('学科'),
backgroundAnalysis: z
.string({ required_error: '请输入当前学科的现状和背景分析' })
.describe('背景及现状分析'),
workGoal: z
.string({ required_error: '请输入教研计划的工作目标' })
.describe('工作目标'),
workFocusAndMeasures: z
.string({
required_error: '请输入教研计划的工作重点和措施',
})
.describe('工作重点和措施'),
})
const form = useForm({
validationSchema: toTypedSchema(schema),
})
const onSubmit = (values: z.infer<typeof schema>) => {
http_stream<z.infer<typeof schema>>(
'/ai/teaching-research-plan/stream',
values,
{
onStart(id, created_at) {
activeConversationId.value = id
updateConversation(id, {
id,
created_at,
title: values.subject,
messages: [
{
role: 'user',
content: `生成教研计划\n学科${values.subject}\n背景及现状分析${values.backgroundAnalysis}\n工作目标${values.workGoal}\n工作重点和措施${values.workFocusAndMeasures}`,
},
{
role: 'assistant',
content: '',
},
],
})
},
onTextChunk: (chunk) => {
appendChunkToLast(activeConversationId.value!, chunk)
},
onComplete: (id, finished_at) => {
updateConversation(id!, {
finished_at,
})
},
},
)
}
</script>
<template>
<div>
<AiConversation
:form
:form-schema="schema"
:form-field-config="{
subject: {
inputProps: {
placeholder: '请输入学科名称,如:大学英语、电力电子技术',
},
},
backgroundAnalysis: {
inputProps: {
placeholder: '请输入当前学科的现状和背景分析',
},
},
workGoal: {
inputProps: {
placeholder: '请输入教研计划的工作目标,如:提升教学质量',
},
},
workFocusAndMeasures: {
inputProps: {
placeholder: '请输入教研计划的工作重点和措施,如:开展教学研讨',
},
},
}"
:conversations
:active-conversation-id="activeConversationId"
disable-user-input
@submit="onSubmit"
@update:conversation-id="activeConversationId = $event"
@delete-conversation="historyStore.removeConversation($event)"
/>
</div>
</template>
<style scoped></style>

View File

@ -46,7 +46,7 @@ onMounted(() => {
})
const schema = z.object({
query: z.string().describe('课程名称'),
query: z.string({ required_error: '请输入课程名称' }).describe('课程名称'),
})
const form = useForm({
@ -54,11 +54,9 @@ const form = useForm({
})
const onSubmit = (values: z.infer<typeof schema>) => {
http_stream(
http_stream<z.infer<typeof schema>>(
'/ai/course-standard/stream',
{
query: values.query,
},
values,
{
onStart(id, created_at) {
activeConversationId.value = id
@ -96,6 +94,13 @@ const onSubmit = (values: z.infer<typeof schema>) => {
<AiConversation
:form
:form-schema="schema"
:form-field-config="{
query: {
inputProps: {
placeholder: '请输入课程名称,如:数据结构与算法',
},
},
}"
:conversations
:active-conversation-id="activeConversationId"
disable-user-input

View File

@ -4,6 +4,7 @@ export interface NavTertiaryItem {
to?: string
component?: string | Component
props?: Record<string, unknown>
disabled?: boolean
}
</script>
@ -27,6 +28,7 @@ const isActiveItem = (idx: number) => {
}
const onClickItem = (idx: number) => {
if (props.navs[idx].disabled) return
emit('update:modelValue', idx)
}
</script>
@ -37,7 +39,7 @@ const onClickItem = (idx: number) => {
v-for="(nav, i) in navs"
:key="i"
class="flex justify-center items-center gap-2 p-2.5 rounded-sm cursor-pointer select-none transition-colors duration-75"
:class="`${isActiveItem(i) ? 'bg-primary text-primary-foreground' : 'bg-accent text-foreground'}`"
:class="`${isActiveItem(i) ? 'bg-primary text-primary-foreground' : 'bg-accent text-foreground'} ${nav.disabled ? 'cursor-not-allowed text-foreground/40' : ''}`"
@click="onClickItem(i)"
>
<span class="text-sm font-medium">

View File

@ -10,6 +10,7 @@ export default withNuxt(
'vue/singleline-html-element-content-newline': 'off',
'@stylistic/brace-style': 'off',
'@stylistic/arrow-parens': 'off',
'@stylistic/operator-linebreak': 'off',
},
plugins: {
prettier,

View File

@ -5,6 +5,11 @@ import {
FnTeachCaseGen,
FnTeachStdDesign,
FnTeachKnowledgeDiagram,
FnTeachCourseChapter,
FnTeachPoliticalCase,
FnTeachResearchPlan,
FnTeachPlan,
FnTeachCourseOutline,
} from '#components'
import type { NavTertiaryItem } from '~/components/nav/Tertiary.vue'
@ -25,9 +30,12 @@ const tertiaryNavs: NavTertiaryItem[] = [
{ label: '教案设计', component: FnTeachLessonPlan },
{ label: '案例设计', component: FnTeachCaseGen },
{ label: '课程标准', component: FnTeachStdDesign },
{ label: '知识图谱', component: FnTeachKnowledgeDiagram },
{ label: '课程章节' },
{ label: '教研计划' },
{ label: '知识图谱', component: FnTeachKnowledgeDiagram, disabled: true },
{ label: '课程章节', component: FnTeachCourseChapter },
{ label: '思政案例', component: FnTeachPoliticalCase },
{ label: '教研计划', component: FnTeachResearchPlan },
{ label: '教学计划', component: FnTeachPlan },
{ label: '课程大纲', component: FnTeachCourseOutline },
]
const currentNav = ref(0)