Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added spx-gui/public/tutorial-intro/code-drag-hint.mov
Binary file not shown.
11 changes: 11 additions & 0 deletions spx-gui/src/apps/xbuilder/.env
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ VITE_USERCONTENT_UPLOAD_BASE_URL=""
# Optional as they have default values.
VITE_DISABLE_AIGC="false"

# Use a local mock Copilot response in Vite development so product walkthroughs
# are not blocked by Account OAuth. This is ignored in production builds.
VITE_COPILOT_LOCAL_PREVIEW="false"

# Version of spx, keep in sync with the version in `install-spx.sh`.
VITE_SPX_VERSION="2.0.4"

Expand Down Expand Up @@ -69,3 +73,10 @@ VITE_DEFAULT_LANG="en"
#
# Required when enabling the new Account OAuth flow.
VITE_ACCOUNT_OAUTH_CLIENT_ID=""

# Optional. Overrides the OAuth redirect URI used by app xbuilder.
#
# Leave empty for normal deployments so the app uses the current web origin.
# Local staging previews can set this to a callback URI registered in the
# staging Account backend.
VITE_ACCOUNT_OAUTH_REDIRECT_URI=""
2 changes: 2 additions & 0 deletions spx-gui/src/apps/xbuilder/.env.staging
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,7 @@ VITE_USERCONTENT_UPLOAD_BASE_URL="https://upload.qiniup.com"
VITE_SENTRY_DSN="https://0d463740215eb87f7e06f6572d64c93e@o4509472134987776.ingest.us.sentry.io/4509472136167424"

VITE_SHOW_TUTORIALS_ENTRY="true"
VITE_COPILOT_LOCAL_PREVIEW="true"

VITE_ACCOUNT_OAUTH_CLIENT_ID="1"
VITE_ACCOUNT_OAUTH_REDIRECT_URI="https://goplus-builder.qiniu.io/sign-in/callback"
3 changes: 3 additions & 0 deletions spx-gui/src/apps/xbuilder/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,11 @@ export const disableAIGC = import.meta.env.VITE_DISABLE_AIGC === 'true'
export const spxVersion = import.meta.env.VITE_SPX_VERSION as string
export const showLicense = import.meta.env.VITE_SHOW_LICENSE === 'true'
export const showTutorialsEntry = import.meta.env.VITE_SHOW_TUTORIALS_ENTRY === 'true'
export const tutorialIntroVideoUrl =
(import.meta.env.VITE_TUTORIAL_INTRO_VIDEO_URL as string) || '/tutorial-intro/code-drag-hint.mov'
export const defaultLang = (import.meta.env.VITE_DEFAULT_LANG as string) || 'en'
export const accountOAuthClientId = import.meta.env.VITE_ACCOUNT_OAUTH_CLIENT_ID as string
export const accountOAuthRedirectUri = (import.meta.env.VITE_ACCOUNT_OAUTH_REDIRECT_URI as string) || undefined
const sentryTracesSampleRate = parseFloat(import.meta.env.VITE_SENTRY_TRACES_SAMPLE_RATE as string)
const sentryLSPSampleRate = parseFloat(import.meta.env.VITE_SENTRY_LSP_SAMPLE_RATE as string)
export const sentry = {
Expand Down
5 changes: 4 additions & 1 deletion spx-gui/src/apps/xbuilder/pages/editor/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
<header class="flex-none">
<EditorNavbar :project="state?.project ?? null" :state="state" />
</header>
<main class="flex-[1_1_0] flex gap-xl p-4 pt-2">
<main class="flex-[1_1_0] flex" :class="isTutorialCourse ? 'gap-0 p-0' : 'gap-xl p-4 pt-2'">
<UIDetailedLoading v-if="allQueryRet.isLoading.value" :percentage="allQueryRet.progress.value.percentage">
<span>{{ $t(allQueryRet.progress.value.desc ?? { en: 'Loading...', zh: '加载中...' }) }}</span>
</UIDetailedLoading>
Expand Down Expand Up @@ -64,6 +64,7 @@ import { cloudHelpers } from '@/models/common/cloud'
import { localHelpers, type LocalHelpers } from '@/models/common/local'
import type { ProjectSerialized } from '@/models/project'
import { SpxProject } from '@/models/spx/project'
import { useMaybeTutorial } from '@/components/tutorials/tutorial'

const props = defineProps<{
ownerNameInput: string
Expand All @@ -72,6 +73,8 @@ const props = defineProps<{
const localCache = new LocalCache(localHelpers)

const signedInStateQuery = useSignedInStateQuery()
const tutorial = useMaybeTutorial()
const isTutorialCourse = computed(() => tutorial?.currentCourse != null)

const router = useRouter()
const routeProjectIdentifier = computed<ProjectIdentifier>(() => ({
Expand Down
23 changes: 2 additions & 21 deletions spx-gui/src/apps/xbuilder/pages/tutorials/course-series.vue
Original file line number Diff line number Diff line change
@@ -1,20 +1,18 @@
<script setup lang="ts">
import { computed } from 'vue'

import { listCourses, type Course } from '@/apis/course'
import { getCourseSeries, type CourseSeries } from '@/apis/course-series'
import { listCourses } from '@/apis/course'
import { getCourseSeries } from '@/apis/course-series'
import ListResultWrapper from '@/components/common/ListResultWrapper.vue'
import CenteredWrapper from '@/components/common/CenteredWrapper.vue'
import CommunityNavbar from '@/components/community/CommunityNavbar.vue'
import TextView from '@/components/community/TextView.vue'
import CourseItem, { courseItemHeight } from '@/components/tutorials/CourseItem.vue'
import { useTutorial } from '@/components/tutorials/tutorial'
import { UICard, UIEmpty, UIError, UIImg, UILoading, UIPagination, useResponsive } from '@/components/ui'
import { createFileWithUniversalUrl } from '@/models/common/cloud'
import { useQuery } from '@/utils/query'
import { useRouteQueryParamInt } from '@/utils/route'
import { useAsyncComputed, usePageTitle } from '@/utils/utils'
import { useMessageHandle } from '@/utils/exception'
import CommunityFooter from '@/components/community/footer/CommunityFooter.vue'
// TODO: Temporary background, replace with the latest assets
import stageBg from '@/assets/images/stage-bg.svg'
Expand All @@ -27,8 +25,6 @@ const props = defineProps<{
courseSeriesIdInput: string
}>()

const tutorial = useTutorial()

const courseSeriesQuery = useQuery(async () => getCourseSeries(props.courseSeriesIdInput), {
en: 'Failed to load course series',
zh: '加载课程系列失败'
Expand Down Expand Up @@ -78,20 +74,6 @@ const courseQuery = useQuery(
{ en: 'Failed to load course list', zh: '加载课程列表失败' }
)

const { fn: handleCourseClick } = useMessageHandle(
(event: MouseEvent, course: Course, courseSeries: CourseSeries) => {
if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey) {
return
}

event.preventDefault()
tutorial.startCourse(course, courseSeries)
},
{
en: 'Failed to start course',
zh: '开始课程失败'
}
)
</script>

<template>
Expand Down Expand Up @@ -154,7 +136,6 @@ const { fn: handleCourseClick } = useMessageHandle(
:key="course.id"
:href="`/course/${courseSeries.id}/${course.id}/start`"
class="no-underline"
@click="handleCourseClick($event, course, courseSeries)"
>
<CourseItem :course="course" />
</a>
Expand Down
84 changes: 84 additions & 0 deletions spx-gui/src/apps/xbuilder/pages/tutorials/course-start.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'

import CourseStart from './course-start.vue'

const mocks = vi.hoisted(() => {
const course = {
id: 'course-1',
owner: 'curator',
title: 'Coding Course 1',
thumbnail: '',
entrypoint: '/editor/curator/Coding-Course-1/sprites/Kiko/code',
references: [],
prompt: 'Help the learner finish the course'
}
const courseSeries = {
id: 'series-1',
owner: 'curator',
title: 'Coding Course',
thumbnail: '',
description: '',
courseIDs: ['course-1'],
order: 1,
createdAt: '',
updatedAt: ''
}
return {
course,
courseSeries,
getCourse: vi.fn(() => Promise.resolve(course)),
getCourseSeries: vi.fn(() => Promise.resolve(courseSeries)),
startCourse: vi.fn(() => Promise.resolve())
}
})

vi.mock('@/apis/course', () => ({
getCourse: mocks.getCourse
}))

vi.mock('@/apis/course-series', () => ({
getCourseSeries: mocks.getCourseSeries
}))

vi.mock('@/components/tutorials/tutorial', () => ({
useTutorial: () => ({
startCourse: mocks.startCourse
})
}))

vi.mock('@/components/tutorials/CourseIntroVideo.vue', () => ({
default: {
props: ['src'],
emits: ['continue'],
template: '<button data-test-id="intro-continue" @click="$emit(\'continue\')">{{ src }}</button>'
}
}))

describe('course-start', () => {
it('waits for the intro video before starting the course', async () => {
const wrapper = mount(CourseStart, {
props: {
courseSeriesIdInput: 'series-1',
courseIdInput: 'course-1'
},
global: {
mocks: {
$t: (msg: { en: string }) => msg.en
}
}
})

await vi.waitFor(() => {
expect(wrapper.find('[data-test-id="intro-continue"]').exists()).toBe(true)
})

expect(mocks.startCourse).not.toHaveBeenCalled()

await wrapper.get('[data-test-id="intro-continue"]').trigger('click')

await vi.waitFor(() => {
expect(mocks.startCourse).toHaveBeenCalledWith(mocks.course, mocks.courseSeries)
})
})
})
46 changes: 42 additions & 4 deletions spx-gui/src/apps/xbuilder/pages/tutorials/course-start.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
<script setup lang="ts">
import { ref, shallowRef } from 'vue'
import { getCourse } from '@/apis/course'
import { getCourseSeries } from '@/apis/course-series'
import { tutorialIntroVideoUrl } from '@/apps/xbuilder/env'
import CourseIntroVideo from '@/components/tutorials/CourseIntroVideo.vue'
import { useTutorial } from '@/components/tutorials/tutorial'
import { UIDetailedLoading, UIError } from '@/components/ui'
import { ActionException, capture, useAction } from '@/utils/exception'
import { composeQuery, useQuery } from '@/utils/query'

const props = defineProps<{
Expand All @@ -27,22 +31,56 @@ const allQueryRet = useQuery(
composeQuery(ctx, courseSeriesQuery, [{ en: 'Loading course series...', zh: '加载课程系列...' }, 1]),
composeQuery(ctx, courseQuery, [{ en: 'Loading course...', zh: '加载课程...' }, 1])
])
await tutorial.startCourse(course, courseSeries)
return { courseSeries, course }
},
{
en: 'Failed to start course',
zh: '启动课程失败'
en: 'Failed to load course',
zh: '加载课程失败'
}
)

const isStartingCourse = ref(false)
const startCourseError = shallowRef<ActionException | null>(null)

const startCourse = useAction(async () => {
const data = allQueryRet.data.value
if (data == null) throw new Error('course data is not loaded')
await tutorial.startCourse(data.course, data.courseSeries)
}, {
en: 'Failed to start course',
zh: '启动课程失败'
})

async function handleIntroContinue() {
startCourseError.value = null
isStartingCourse.value = true
try {
await startCourse()
} catch (e) {
isStartingCourse.value = false
if (e instanceof ActionException) {
startCourseError.value = e
} else {
capture(e, 'start course')
}
}
}
</script>

<template>
<section class="h-full w-full flex items-center justify-center">
<UIDetailedLoading v-if="allQueryRet.isLoading.value" :percentage="allQueryRet.progress.value.percentage">
<UIDetailedLoading
v-if="allQueryRet.isLoading.value || isStartingCourse"
:percentage="isStartingCourse ? 100 : allQueryRet.progress.value.percentage"
>
<span>{{ $t(allQueryRet.progress.value.desc ?? { zh: '跳转中...', en: 'Redirecting...' }) }}</span>
</UIDetailedLoading>
<UIError v-else-if="allQueryRet.error.value != null" :retry="allQueryRet.refetch">
{{ $t(allQueryRet.error.value.userMessage) }}
</UIError>
<UIError v-else-if="startCourseError != null" :retry="handleIntroContinue">
{{ $t(startCourseError.userMessage) }}
</UIError>
<CourseIntroVideo v-else-if="allQueryRet.data.value != null" :src="tutorialIntroVideoUrl" @continue="handleIntroContinue" />
</section>
</template>
2 changes: 1 addition & 1 deletion spx-gui/src/apps/xbuilder/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export function setupXBuilder() {
baseUrl: env.usercontentBaseUrl,
bucket: env.usercontentBucket
})
initUserState(env.accountOAuthClientId)
initUserState(env.accountOAuthClientId, { redirectUri: env.accountOAuthRedirectUri })
setup()
}

Expand Down
6 changes: 5 additions & 1 deletion spx-gui/src/components/copilot/CopilotRoot.vue
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { useSignedInStateQuery, type SignedInState } from '@/stores/user'
import { userSessionStorageRef } from '@/utils/user-storage'
import { provideCopilot } from './context'
import { createBuiltInSkillRegistry } from './skills/built-in'
import { createLocalPreviewCopilotGenerator, shouldUseLocalPreviewCopilot } from './local-preview-generator'

const listProjectsParamsSchema = z.object({
owner: z
Expand Down Expand Up @@ -167,7 +168,10 @@ const modalEvents = useModalEvents()
const messageEvents = useMessageEvents()
const signedInStateQuery = useSignedInStateQuery()
const skillRegistry = createBuiltInSkillRegistry()
const copilot = new Copilot(skillRegistry)
const copilot = new Copilot(
skillRegistry,
shouldUseLocalPreviewCopilot() ? createLocalPreviewCopilotGenerator() : undefined
)
const sessionStorageRef = userSessionStorageRef<SessionExported | null>('spx-gui-copilot-session', null)

copilot.syncSessionWith({
Expand Down
Loading
Loading