{{ $t({ en: 'Please sign in to continue.', zh: '请先登录并继续' }) }}
{{ $t({ en: 'Sign in', zh: '登录' }) }}
diff --git a/spx-gui/src/components/copilot/local-preview-generator.test.ts b/spx-gui/src/components/copilot/local-preview-generator.test.ts
new file mode 100644
index 0000000000..00caa0b796
--- /dev/null
+++ b/spx-gui/src/components/copilot/local-preview-generator.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it } from 'vitest'
+import type { MessageEvent } from '@/apis/copilot'
+import { createLocalPreviewCopilotGenerator } from './local-preview-generator'
+
+async function collectEvents(events: AsyncIterableIterator) {
+ const result: MessageEvent[] = []
+ for await (const event of events) result.push(event)
+ return result
+}
+
+describe('local preview copilot generator', () => {
+ it('streams a local walkthrough response without calling the remote API', async () => {
+ const generator = createLocalPreviewCopilotGenerator()
+
+ const events = await collectEvents(
+ generator.generateCopilotMessage([
+ {
+ role: 'user',
+ content: {
+ type: 'text',
+ text: 'Fix the current problem'
+ }
+ }
+ ])
+ )
+
+ expect(events).toEqual([
+ {
+ type: 'text_delta',
+ data: {
+ text: expect.stringContaining('本地预览模式')
+ }
+ },
+ {
+ type: 'done',
+ data: {
+ finishReason: 'stop'
+ }
+ }
+ ])
+ })
+
+ it('streams tutorial guidance when the current session is a course', async () => {
+ const generator = createLocalPreviewCopilotGenerator()
+
+ const events = await collectEvents(
+ generator.generateCopilotMessage([
+ {
+ role: 'user',
+ content: {
+ type: 'text',
+ text: 'Coding-Course-3'
+ }
+ }
+ ])
+ )
+
+ expect(events[0]).toEqual({
+ type: 'text_delta',
+ data: {
+ text: expect.stringContaining('教程 Copilot')
+ }
+ })
+ })
+})
diff --git a/spx-gui/src/components/copilot/local-preview-generator.ts b/spx-gui/src/components/copilot/local-preview-generator.ts
new file mode 100644
index 0000000000..76acc9333b
--- /dev/null
+++ b/spx-gui/src/components/copilot/local-preview-generator.ts
@@ -0,0 +1,43 @@
+import type { MessageEvent } from '@/apis/copilot'
+import type { Message } from '@/apis/copilot'
+import type { IMessageEventGenerator } from './copilot'
+
+const tutorialGuideResponse = `我会作为教程 Copilot 帮你完成当前课程。
+
+先看左侧 API 列表,本课程会用到的积木已经筛选出来了。请把需要的 API 拖到代码编辑区,优先完成当前步骤;如果运行结果不符合预期,再告诉我你看到的现象,我会继续给下一步提示。`
+
+const localPreviewResponse = `我正在本地预览模式下运行,可以在不登录的情况下检查教程引导、消息流程和交互状态。
+
+${tutorialGuideResponse}`
+
+function isTutorialMessage(messages: Message[]) {
+ return messages.some((message) => {
+ if (message.content?.type !== 'text') return false
+ const text = message.content.text
+ return text.includes('') || text.includes('Course Started') || text.includes('课程开始')
+ })
+}
+
+export function createLocalPreviewCopilotGenerator(): IMessageEventGenerator {
+ return {
+ async *generateCopilotMessage(messages): AsyncIterableIterator {
+ const response = isTutorialMessage(messages) ? tutorialGuideResponse : localPreviewResponse
+ yield {
+ type: 'text_delta',
+ data: {
+ text: response
+ }
+ }
+ yield {
+ type: 'done',
+ data: {
+ finishReason: 'stop'
+ }
+ }
+ }
+ }
+}
+
+export function shouldUseLocalPreviewCopilot() {
+ return import.meta.env.DEV && import.meta.env.VITE_COPILOT_LOCAL_PREVIEW === 'true'
+}
diff --git a/spx-gui/src/components/editor/ProjectEditor.vue b/spx-gui/src/components/editor/ProjectEditor.vue
index c0333d0e52..96d02c5506 100644
--- a/spx-gui/src/components/editor/ProjectEditor.vue
+++ b/spx-gui/src/components/editor/ProjectEditor.vue
@@ -1,33 +1,52 @@
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
editorCtx.project)
const selected = computed(() => editorCtx.state.selected)
const isPreviewMode = computed(() => editorCtx.state.selectedEditMode === EditMode.Default)
+const isTutorialCourse = computed(() => tutorial?.currentCourse != null)
useSpxEditorCopilot()
@@ -59,3 +81,36 @@ function handleSpriteSelect(spriteId: string | null) {
editorCtx.state.selectSprite(spriteId)
}
+
+
diff --git a/spx-gui/src/components/editor/navbar/EditorNavbar.vue b/spx-gui/src/components/editor/navbar/EditorNavbar.vue
index d73ef98e49..d202906ab8 100644
--- a/spx-gui/src/components/editor/navbar/EditorNavbar.vue
+++ b/spx-gui/src/components/editor/navbar/EditorNavbar.vue
@@ -69,7 +69,8 @@
-
+
+
@@ -99,6 +100,7 @@
{
})
const selectedEditMode = computed(() => props.state?.selectedEditMode ?? EditMode.Default)
+const isTutorialCourse = computed(() => tutorial?.currentCourse != null)
const importProjectFileMessage = { en: 'Import project file', zh: '导入项目文件' }
diff --git a/spx-gui/src/components/editor/preview/EditorPreview.vue b/spx-gui/src/components/editor/preview/EditorPreview.vue
index 8bda9dc3de..97ba371024 100644
--- a/spx-gui/src/components/editor/preview/EditorPreview.vue
+++ b/spx-gui/src/components/editor/preview/EditorPreview.vue
@@ -1,9 +1,11 @@
-
-
+
-
+
-
+
+
+
+
+
+
+
+
+
diff --git a/spx-gui/src/components/navbar/NavbarProfile.vue b/spx-gui/src/components/navbar/NavbarProfile.vue
index 229fb19e65..58b0512eb3 100644
--- a/spx-gui/src/components/navbar/NavbarProfile.vue
+++ b/spx-gui/src/components/navbar/NavbarProfile.vue
@@ -18,6 +18,17 @@ const { isOnline } = useNetwork()
const router = useRouter()
const i18n = useI18n()
const signIn = useSignIn()
+const handleSignIn = useMessageHandle(signIn, {
+ en: 'Failed to sign in',
+ zh: '登录失败'
+}).fn
+let signInPromise: Promise
| null = null
+function handleSignInOnce() {
+ if (signInPromise != null) return
+ signInPromise = handleSignIn().finally(() => {
+ signInPromise = null
+ })
+}
const signedInStateQuery = useSignedInStateQuery()
const loading = computed(() => signedInStateQuery.isLoading.value)
@@ -68,7 +79,8 @@ async function handleSignOut() {
v-radar="{ name: 'Sign-in button', desc: 'Click to sign in' }"
type="secondary"
:disabled="!isOnline"
- @click="signIn()"
+ @pointerup="handleSignInOnce"
+ @click="handleSignInOnce"
>{{ $t({ en: 'Sign in', zh: '登录' }) }}
diff --git a/spx-gui/src/components/project/runner/ProjectRunner.vue b/spx-gui/src/components/project/runner/ProjectRunner.vue
index cadebc72a6..1a459359cc 100644
--- a/spx-gui/src/components/project/runner/ProjectRunner.vue
+++ b/spx-gui/src/components/project/runner/ProjectRunner.vue
@@ -385,7 +385,7 @@ defineExpose({
-
+
msg.en
+ }
+ }
+ })
+}
+
+describe('CourseIntroVideo', () => {
+ afterEach(() => {
+ vi.restoreAllMocks()
+ })
+
+ it('renders the configured video source', () => {
+ const wrapper = mountVideo()
+
+ expect(wrapper.get('video').attributes('src')).toBe('/tutorial-intro/code-drag-hint.mov')
+ })
+
+ it('plays the video from the overlay action', async () => {
+ const play = vi.spyOn(HTMLMediaElement.prototype, 'play').mockResolvedValue()
+ const wrapper = mountVideo()
+
+ await wrapper.get('.course-intro-play').trigger('click')
+
+ expect(play).toHaveBeenCalledTimes(1)
+ })
+
+ it('continues to the course when the video ends', async () => {
+ const wrapper = mountVideo()
+
+ await wrapper.get('video').trigger('ended')
+
+ expect(wrapper.emitted('continue')).toHaveLength(1)
+ })
+
+ it('lets learners skip the intro', async () => {
+ const wrapper = mountVideo()
+
+ await wrapper.get('.course-intro-skip').trigger('click')
+
+ expect(wrapper.emitted('continue')).toHaveLength(1)
+ })
+})
diff --git a/spx-gui/src/components/tutorials/CourseIntroVideo.vue b/spx-gui/src/components/tutorials/CourseIntroVideo.vue
new file mode 100644
index 0000000000..0b729e7d5f
--- /dev/null
+++ b/spx-gui/src/components/tutorials/CourseIntroVideo.vue
@@ -0,0 +1,198 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{ $t({ en: 'The video cannot be played right now.', zh: '视频暂时无法播放。' }) }}
+
+
+
+
+
+
+
+
+
+
diff --git a/spx-gui/src/components/tutorials/TutorialCourseExitLink.ts b/spx-gui/src/components/tutorials/TutorialCourseExitLink.ts
index 864fb9eb05..66ec26a27c 100644
--- a/spx-gui/src/components/tutorials/TutorialCourseExitLink.ts
+++ b/spx-gui/src/components/tutorials/TutorialCourseExitLink.ts
@@ -1,9 +1,11 @@
import { z } from 'zod'
import { defineComponent, h } from 'vue'
+import { useRouter } from 'vue-router'
import { useMessageHandle } from '@/utils/exception'
import { useI18n } from '@/utils/i18n'
import { useChildrenWithDefault } from '@/utils/vnode'
import { useTutorial } from './tutorial'
+import { exitCurrentTutorial } from './tutorial-exit'
export const tagName = 'tutorial-course-exit-link'
@@ -23,12 +25,13 @@ export default defineComponent(
() => {
const i18n = useI18n()
const tutorial = useTutorial()
+ const router = useRouter()
const handleClick = useMessageHandle(
() => {
if (!tutorial.currentCourse || !tutorial.currentSeries) {
throw new Error('No course or series in progress')
}
- tutorial.endCurrentCourse()
+ return exitCurrentTutorial(tutorial, router)
},
{
en: 'Failed to exit course',
diff --git a/spx-gui/src/components/tutorials/TutorialNavbarExit.vue b/spx-gui/src/components/tutorials/TutorialNavbarExit.vue
new file mode 100644
index 0000000000..52fddcad5e
--- /dev/null
+++ b/spx-gui/src/components/tutorials/TutorialNavbarExit.vue
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+ {{ $t({ en: 'Exit Tutorial', zh: '退出教程' }) }}
+
+
+
+
+
+
+
+
diff --git a/spx-gui/src/components/tutorials/TutorialStateIndicator.vue b/spx-gui/src/components/tutorials/TutorialStateIndicator.vue
index 256a7c43fc..e008d9fcc4 100644
--- a/spx-gui/src/components/tutorials/TutorialStateIndicator.vue
+++ b/spx-gui/src/components/tutorials/TutorialStateIndicator.vue
@@ -4,12 +4,15 @@ export const name = 'tutorial-state-indicator'
-
+